Summary
本 issue 解决问股 Agent 在多轮追问中容易从当前个股上下文跑偏到错误标的的问题。核心场景是:会话已经明确围绕某个当前股票展开,用户后续追问里的金融术语、指标缩写或普通英文大写词,不应被重新解释成新的美股 ticker。
公开 issue 中不写具体股票名称和具体股票代码。下面统一用 <CURRENT_STOCK> / <CURRENT_STOCK_CODE> 表示问题产生的真实当前标的,用 <UNRELATED_US_TICKER> 表示被误切换到的无关美股解释。
这次暴露的问题是:首轮分析围绕 <CURRENT_STOCK> 展开,回答里提到了“市盈率(TTM)”“按近期利润年化后的估值”“回购空间”等基本面判断。用户随后追问“如果不考虑 TTM 呢”。在这个上下文里,TTM 明确是 trailing twelve months 估值口径,不是用户要切换标的。但 Agent 把 TTM 当成一个新的美股 ticker 触发检索和分析,生成了完全偏离 <CURRENT_STOCK> 的回答。用户再纠正“我说的是刚才那个标的”后,Agent 才回到原来的股票。
这不是单纯模型幻觉。现有代码链路给了模型和工具调用层足够大的跑偏空间:
- 前端从历史报告进入问股时,首轮请求会携带
context.stock_code / context.stock_name,但这份 follow-up context 是一次性的,发送后会清空。
- 第二轮及以后主要依赖
session_id 下的历史消息或上下文压缩摘要维持语义,不再稳定携带结构化当前标的。
- 前端和后端都存在“2-5 位大写英文像美股 ticker”的宽松识别规则,
TTM 正好命中。
- single-agent ReAct 路径把历史分析上下文作为普通 user/assistant 文本注入,不是不可违背的运行态约束。
- 模型一旦在 tool call 中填入错误
stock_code,runner 会原样执行工具,工具 registry 层没有针对当前会话标的的校验、拒绝或改写机制。
- multi-agent / bot 预解析路径已有一部分金融术语黑名单,但没有系统覆盖
TTM、LTM、NTM、FWD、YOY、QOQ、YTD、EBITDA、DCF、CAGR 等常见财务缩写。
目标不是只把 TTM 加进黑名单,而是建立一套“金融术语消歧 + 会话级当前标的锁定 + 工具参数护栏”的稳定机制。用户在同一问股会话里追问“如果不考虑 TTM”“PE 怎么看”“MACD 还没金叉吗”“不算回购呢”时,默认都应继续围绕当前股票;只有用户明确表达“换成 / 分析 / 看看 / 比较 + 新代码或新名称”时才切换标的。
How This Was Found
这个问题来自真实桌面端交互,不是代码审查里预设出来的。
复现场景如下:
- 在桌面端
问股 - DSA 页面打开一个已有个股分析对话。
- 首轮用户问题明确包含
<CURRENT_STOCK> 和 <CURRENT_STOCK_CODE>,并询问是否可以建仓。
- Agent 首轮回答正常围绕当前标的,技术面、基本面和估值段落都在讨论
<CURRENT_STOCK>。
- 用户选中了回答中关于
TTM 估值口径、年化利润推算和回购约束的一段文字。
- 用户追问“如果不考虑 TTM 呢”。结合选中文本和前文语义,这里的
TTM 只能理解为市盈率口径,不是新标的。
- Agent 却输出了“关于 TTM / 某个无关美股标的”的分析,并围绕完全不同的公司和市场做判断。
- 用户指出“你搞错了,我说的是刚才那个标的”。Agent 随后回到原标的,但这说明系统已经发生过错误标的切换。
- 用户再次以“如果不考虑他的 TTM 呢”追问时,仍然能看到同类风险:系统需要靠上下文自觉理解,而不是靠结构化约束保证不跑偏。
这个呈现方式很关键:问题不是用户输入了一个孤立的 TTM,而是在已有股票会话、已有选中文本、已有估值段落的上下文中,系统仍然把指标缩写解释成了新 ticker。
Investigation Notes
我按前端请求链路、后端 single-agent 链路、multi-agent / 预解析链路、数据源归类链路四条线排查。
Frontend Request Chain
相关位置:
apps/dsa-web/src/pages/HomePage.tsx:从历史报告点击追问 AI 时,会把报告中的 stock code、stock name、record id 拼进 /chat URL。
apps/dsa-web/src/pages/ChatPage.tsx:读取 URL 参数,设置输入框、activeStockCode 和一次性的 followUpContextRef。
apps/dsa-web/src/utils/chatFollowUp.ts:构造 ChatFollowUpContext,字段包含 stock_code、stock_name、previous_analysis_summary、previous_strategy、previous_price、previous_change_pct。
apps/dsa-web/src/pages/ChatPage.tsx:发送 payload 时,context 来自 followUpContextRef.current,发送后立即清空该 ref。
apps/dsa-web/src/utils/chatStockCode.ts:从用户输入提取股票代码,包含宽松的 2-5 位大写英文识别。
apps/dsa-web/src/utils/validation.ts:股票代码校验允许 1-5 位大写英文和可选市场后缀。
因此首轮从报告进入问股时通常有结构化 context:
{
"message": "请深入分析 <CURRENT_STOCK>",
"session_id": "...",
"skills": ["..."],
"context": {
"stock_code": "<CURRENT_STOCK_CODE>",
"stock_name": "<CURRENT_STOCK>"
}
}
但第二轮及以后通常只剩:
{
"message": "如果不考虑 TTM 呢",
"session_id": "...",
"skills": ["..."]
}
这意味着前端没有把“当前标的”作为 sticky state 持续传给后端。
Backend Single-agent Chain
相关位置:
api/v1/endpoints/agent.py:/api/v1/agent/chat 和 /api/v1/agent/chat/stream 复制 request.context,然后把 request.message 交给 executor。
src/agent/executor.py:chat() 构造 system prompt、暴露 tool schema,并通过 build_agent_chat_context_bundle() 注入可见历史。
src/agent/executor.py:如果有 context.stock_code,会把它拼成普通文本上下文,并追加 assistant 确认消息。这对模型有提示作用,但不是强制约束。
src/agent/runner.py:模型返回 tool calls 后,runner 直接把 tc.arguments 传给 tool_registry.execute()。
src/agent/tools/registry.py:registry 负责 schema 生成和函数分发,不校验 tool argument 里的 stock_code 是否与当前会话标的一致。
src/agent/tools/data_tools.py、analysis_tools.py、search_tools.py、backtest_tools.py:大量工具参数都是普通 stock_code: string。
因此在 single-agent 路径中,只要模型把 TTM 填进工具参数,系统就会继续执行错误标的的数据获取、新闻搜索和技术分析。
Multi-agent And Pre-parse Chain
相关位置:
src/agent/orchestrator.py 的 _extract_stock_code() 会在没有 context.stock_code 时从用户文本中抽取股票代码。
_COMMON_WORDS 已过滤一批金融缩写,例如 ETF、IPO、RSI、EPS、PEG、ROE、ROA、MACD、VWAP、BOLL。
- 但
_COMMON_WORDS 缺少 TTM 等估值和财务分析常用缩写。
- 当前逻辑会把 2-5 位大写英文视为 US ticker 候选。
只读验证结果显示:
_extract_stock_code("市盈率 TTM 怎么看") 会返回 TTM。
is_us_stock_code("TTM") 为 True。
- 数据源 market tag 会把
TTM 归类为美股。
这说明即使不依赖 LLM 工具选择,后端已有的自然语言股票代码抽取也可能误判。
Data Provider Classification
相关位置:
data_provider/us_index_mapping.py 的美股代码正则允许 1-5 位大写英文。
data_provider/base.py 会用 is_us_stock_code() 判定美股并选择美股 source order。
某个具体公司名从哪里出现并不是根因。它可能来自模型先验、外部搜索结果或行情源对 TTM 的解释。真正的触发点是:系统允许一个财务指标缩写进入“新股票代码”路径。
Root Cause
根因是三个条件叠加:
- 当前标的没有被建模成会话级结构化状态。首轮 follow-up context 是一次性的,后续追问没有稳定携带当前 stock context。
- 股票代码识别规则过宽。裸 2-5 位大写英文被当成美股 ticker,但金融场景中大量指标缩写也是 2-5 位大写英文。
- 工具调用缺少最终护栏。模型生成的
stock_code 与当前会话标的不一致时,runner / registry / tool 层没有拒绝、改写或要求澄清。
上下文压缩会放大这个问题,但不是根因。即使不压缩,只要模型把 TTM 当成 ticker 并发起 tool call,当前工具链也会执行错误标的。
Design Direction
建议只拆成两个 phase,而不是把每个实现细节拆成独立阶段。
Phase 1: 消歧止血
目标是用小 diff 立刻阻断已知误判,避免财务缩写被当作新股票代码。
范围:
- 建立或扩展金融术语排除表,至少覆盖:
TTM
LTM
NTM
FWD
YOY
QOQ
YTD
EBIT
EBITDA
DCF
CAGR
FCF
NAV
AUM
PE
PB
- 后端
_extract_stock_code() 不应把这些词识别为新 ticker。
- 前端
chatStockCode.ts / validation.ts 同步排除这些金融术语,避免 UI 的 active stock 被指标缩写误改。
- 当用户消息包含“市盈率、估值、利润、同比、环比、财报、指标、回购、TTM、PE、PB”等语义时,对裸大写词采取更保守策略。
- 保留正常美股 ticker 输入能力。测试里可使用明确标注为股票代码的 fixture,而不是依赖当前问题里的真实标的。
- 补前后端回归测试,覆盖“当前会话里追问 TTM 口径”不会触发新股票识别。
Phase 1 是止血,不应在 PR body 里声称完全解决所有上下文跑偏问题。
Phase 2: 会话级当前标的契约
目标是把“当前正在讨论哪只股票”从自然语言历史提升为结构化运行态约束。
范围:
- 前端维护会话级
activeStockContext,至少包含:
stock_code
stock_name
source:url / message / user_switch / history_report
last_confirmed_at
- 从报告页进入问股后,后续每一轮追问默认继续发送
context.stock_code / context.stock_name。
- 当前输入中没有明确新标的时,不清空 active stock。
- 当前输入中出现金融指标、策略名、英文缩写、行业词时,不触发标的切换。
- 只有明确表达新标的时才更新 active stock,例如:
分析 <NEW_STOCK_CODE>
看看 <NEW_STOCK_NAME>
换成 <NEW_STOCK_CODE>
比较 <CURRENT_STOCK_CODE> 和 <NEW_STOCK_CODE>
- 后端在 executor / runner / tool registry 之间增加 stock-scoped tool guard。
- 当
context.stock_code 存在,而模型 tool call 里的 stock_code 与当前 context 不一致时:
- 如果本轮用户没有明确切换标的,则拒绝、改写为当前标的,或要求模型澄清。
- 如果本轮用户明确切换标的,则允许并更新本轮 context。
- 如果无法判断,则保守澄清,不直接分析另一个标的。
- guard 覆盖所有 stock-scoped tools,包括实时行情、历史行情、趋势分析、筹码、股票信息、新闻搜索、综合情报、回测或策略类单股工具。
- guard 行为写入 diagnostics / tool call log,方便复盘“模型想切换标的但被拦截”。
- 上下文压缩可以继续保留自然语言摘要,但 active stock 必须作为结构化字段独立维护或每轮注入。
- 如用户可见行为变化,需要同步专题文档和
docs/CHANGELOG.md。
Progress
| Phase |
状态 |
PR |
说明 |
| Baseline |
已确认 |
- |
问股 Web、Agent executor、multi-agent orchestrator、上下文压缩、工具调用链路已存在 |
| Baseline |
已确认 |
- |
首轮历史报告追问可携带 stock context,但后续追问不会稳定继承 |
| Baseline |
已确认 |
- |
后端已有部分金融术语黑名单,但未覆盖 TTM 等财务缩写 |
| Phase 1 |
已合并 |
#1604 |
金融术语消歧与已知误判止血 |
| Phase 2 |
未开始 |
- |
会话级当前标的锁定与后端 tool guard |
Acceptance Criteria
Test Plan
- Backend unit tests:覆盖
_extract_stock_code() 对 TTM、PE TTM、YOY、QOQ、EBITDA 等金融术语的排除。
- Backend unit tests:覆盖
_extract_stock_code() 对明确股票代码 fixture 的保留,确保正常美股 ticker 输入不被破坏。
- Agent tests:有
context.stock_code=<CURRENT_STOCK_CODE> 时,用户追问“如果不考虑 TTM 呢”不会触发 stock_code=TTM 的工具调用。
- Agent tests:用户明确“换成 <NEW_STOCK_CODE>”时,允许切换标的。
- Tool guard tests:模型传入冲突
stock_code 时,guard 能记录并阻断或改写。
- Frontend unit tests:
extractStockCodeFromMessage("如果不考虑 TTM 呢") 返回 null。
- Frontend unit tests:
extractStockCodeFromMessage("分析 <US_STOCK_FIXTURE> 走势") 仍返回该 fixture。
- Frontend ChatPage tests:报告页进入问股后,后续追问继续发送 active stock context。
- Frontend ChatPage tests:金融缩写不会更新 UI
activeStockCode。
- Compression tests:开启
AGENT_CONTEXT_COMPRESSION_ENABLED 后,结构化 active stock 仍被传递或注入。
- Regression tests:复现匿名化后的截图对话序列,确认第二轮不会输出无关美股标的分析。
- Validation:后端改动执行
python -m pytest -m "not network" 或受影响测试;前端改动执行 cd apps/dsa-web && npm run lint && npm run build。
- Docs:如有文档更新,确认
docs/CHANGELOG.md 的 [Unreleased] 仍使用扁平格式。
Compatibility And Risk
- 不能简单删除美股 ticker 识别,否则会破坏真实美股输入能力。
- 不能只依赖 prompt 要求“不要跑偏”,因为模型 tool call 仍可能填错参数。
- 不能只修前端,Bot/API/multi-agent 路径仍可能误判。
- 不能只修后端
_COMMON_WORDS,因为前端 UI 仍可能把指标缩写显示成当前股票。
- 会话级 sticky stock 需要明确切换规则,避免用户真的想换标的时被错误锁死。
- 如果新增配置项,必须同步
.env.example、Web 设置帮助、专题文档和 changelog。
Non-goals
- 不公开截图里的真实股票名称或代码。
- 不重写整个问股 Agent 架构。
- 不移除美股 ticker 支持。
- 不要求一次 PR 完成两个 phase;Phase 1 可以先止血,Phase 2 再补结构化契约。
- 不做股票名称全量实体识别重构;名称识别可以作为后续增强。
- 不把所有金融术语都硬编码成不可分析对象;无当前标的且用户明确表达“分析某个 ticker”时,可以澄清或走 ticker 路径。
- 不改变普通个股分析、每日分析、大盘复盘、告警中心和持仓页的默认行为。
Summary
本 issue 解决问股 Agent 在多轮追问中容易从当前个股上下文跑偏到错误标的的问题。核心场景是:会话已经明确围绕某个当前股票展开,用户后续追问里的金融术语、指标缩写或普通英文大写词,不应被重新解释成新的美股 ticker。
公开 issue 中不写具体股票名称和具体股票代码。下面统一用
<CURRENT_STOCK>/<CURRENT_STOCK_CODE>表示问题产生的真实当前标的,用<UNRELATED_US_TICKER>表示被误切换到的无关美股解释。这次暴露的问题是:首轮分析围绕
<CURRENT_STOCK>展开,回答里提到了“市盈率(TTM)”“按近期利润年化后的估值”“回购空间”等基本面判断。用户随后追问“如果不考虑 TTM 呢”。在这个上下文里,TTM明确是 trailing twelve months 估值口径,不是用户要切换标的。但 Agent 把TTM当成一个新的美股 ticker 触发检索和分析,生成了完全偏离<CURRENT_STOCK>的回答。用户再纠正“我说的是刚才那个标的”后,Agent 才回到原来的股票。这不是单纯模型幻觉。现有代码链路给了模型和工具调用层足够大的跑偏空间:
context.stock_code/context.stock_name,但这份 follow-up context 是一次性的,发送后会清空。session_id下的历史消息或上下文压缩摘要维持语义,不再稳定携带结构化当前标的。TTM正好命中。stock_code,runner 会原样执行工具,工具 registry 层没有针对当前会话标的的校验、拒绝或改写机制。TTM、LTM、NTM、FWD、YOY、QOQ、YTD、EBITDA、DCF、CAGR等常见财务缩写。目标不是只把
TTM加进黑名单,而是建立一套“金融术语消歧 + 会话级当前标的锁定 + 工具参数护栏”的稳定机制。用户在同一问股会话里追问“如果不考虑 TTM”“PE 怎么看”“MACD 还没金叉吗”“不算回购呢”时,默认都应继续围绕当前股票;只有用户明确表达“换成 / 分析 / 看看 / 比较 + 新代码或新名称”时才切换标的。How This Was Found
这个问题来自真实桌面端交互,不是代码审查里预设出来的。
复现场景如下:
问股 - DSA页面打开一个已有个股分析对话。<CURRENT_STOCK>和<CURRENT_STOCK_CODE>,并询问是否可以建仓。<CURRENT_STOCK>。TTM估值口径、年化利润推算和回购约束的一段文字。TTM只能理解为市盈率口径,不是新标的。这个呈现方式很关键:问题不是用户输入了一个孤立的
TTM,而是在已有股票会话、已有选中文本、已有估值段落的上下文中,系统仍然把指标缩写解释成了新 ticker。Investigation Notes
我按前端请求链路、后端 single-agent 链路、multi-agent / 预解析链路、数据源归类链路四条线排查。
Frontend Request Chain
相关位置:
apps/dsa-web/src/pages/HomePage.tsx:从历史报告点击追问 AI 时,会把报告中的 stock code、stock name、record id 拼进/chatURL。apps/dsa-web/src/pages/ChatPage.tsx:读取 URL 参数,设置输入框、activeStockCode和一次性的followUpContextRef。apps/dsa-web/src/utils/chatFollowUp.ts:构造ChatFollowUpContext,字段包含stock_code、stock_name、previous_analysis_summary、previous_strategy、previous_price、previous_change_pct。apps/dsa-web/src/pages/ChatPage.tsx:发送 payload 时,context来自followUpContextRef.current,发送后立即清空该 ref。apps/dsa-web/src/utils/chatStockCode.ts:从用户输入提取股票代码,包含宽松的 2-5 位大写英文识别。apps/dsa-web/src/utils/validation.ts:股票代码校验允许 1-5 位大写英文和可选市场后缀。因此首轮从报告进入问股时通常有结构化 context:
{ "message": "请深入分析 <CURRENT_STOCK>", "session_id": "...", "skills": ["..."], "context": { "stock_code": "<CURRENT_STOCK_CODE>", "stock_name": "<CURRENT_STOCK>" } }但第二轮及以后通常只剩:
{ "message": "如果不考虑 TTM 呢", "session_id": "...", "skills": ["..."] }这意味着前端没有把“当前标的”作为 sticky state 持续传给后端。
Backend Single-agent Chain
相关位置:
api/v1/endpoints/agent.py:/api/v1/agent/chat和/api/v1/agent/chat/stream复制request.context,然后把request.message交给 executor。src/agent/executor.py:chat()构造 system prompt、暴露 tool schema,并通过build_agent_chat_context_bundle()注入可见历史。src/agent/executor.py:如果有context.stock_code,会把它拼成普通文本上下文,并追加 assistant 确认消息。这对模型有提示作用,但不是强制约束。src/agent/runner.py:模型返回 tool calls 后,runner 直接把tc.arguments传给tool_registry.execute()。src/agent/tools/registry.py:registry 负责 schema 生成和函数分发,不校验 tool argument 里的stock_code是否与当前会话标的一致。src/agent/tools/data_tools.py、analysis_tools.py、search_tools.py、backtest_tools.py:大量工具参数都是普通stock_code: string。因此在 single-agent 路径中,只要模型把
TTM填进工具参数,系统就会继续执行错误标的的数据获取、新闻搜索和技术分析。Multi-agent And Pre-parse Chain
相关位置:
src/agent/orchestrator.py的_extract_stock_code()会在没有context.stock_code时从用户文本中抽取股票代码。_COMMON_WORDS已过滤一批金融缩写,例如ETF、IPO、RSI、EPS、PEG、ROE、ROA、MACD、VWAP、BOLL。_COMMON_WORDS缺少TTM等估值和财务分析常用缩写。只读验证结果显示:
_extract_stock_code("市盈率 TTM 怎么看")会返回TTM。is_us_stock_code("TTM")为 True。TTM归类为美股。这说明即使不依赖 LLM 工具选择,后端已有的自然语言股票代码抽取也可能误判。
Data Provider Classification
相关位置:
data_provider/us_index_mapping.py的美股代码正则允许 1-5 位大写英文。data_provider/base.py会用is_us_stock_code()判定美股并选择美股 source order。某个具体公司名从哪里出现并不是根因。它可能来自模型先验、外部搜索结果或行情源对
TTM的解释。真正的触发点是:系统允许一个财务指标缩写进入“新股票代码”路径。Root Cause
根因是三个条件叠加:
stock_code与当前会话标的不一致时,runner / registry / tool 层没有拒绝、改写或要求澄清。上下文压缩会放大这个问题,但不是根因。即使不压缩,只要模型把
TTM当成 ticker 并发起 tool call,当前工具链也会执行错误标的。Design Direction
建议只拆成两个 phase,而不是把每个实现细节拆成独立阶段。
Phase 1: 消歧止血
目标是用小 diff 立刻阻断已知误判,避免财务缩写被当作新股票代码。
范围:
TTMLTMNTMFWDYOYQOQYTDEBITEBITDADCFCAGRFCFNAVAUMPEPB_extract_stock_code()不应把这些词识别为新 ticker。chatStockCode.ts/validation.ts同步排除这些金融术语,避免 UI 的 active stock 被指标缩写误改。Phase 1 是止血,不应在 PR body 里声称完全解决所有上下文跑偏问题。
Phase 2: 会话级当前标的契约
目标是把“当前正在讨论哪只股票”从自然语言历史提升为结构化运行态约束。
范围:
activeStockContext,至少包含:stock_codestock_namesource:url / message / user_switch / history_reportlast_confirmed_atcontext.stock_code/context.stock_name。分析 <NEW_STOCK_CODE>看看 <NEW_STOCK_NAME>换成 <NEW_STOCK_CODE>比较 <CURRENT_STOCK_CODE> 和 <NEW_STOCK_CODE>context.stock_code存在,而模型 tool call 里的stock_code与当前 context 不一致时:docs/CHANGELOG.md。Progress
TTM等财务缩写Acceptance Criteria
TTM、LTM、NTM、FWD、YOY、QOQ、YTD、EBITDA、DCF、CAGR等金融术语不会被前端或后端当作新股票代码。extractStockCodeFromMessage("如果不考虑 TTM 呢")返回 null。_extract_stock_code("市盈率 TTM 怎么看")返回空字符串。stock_code/stock_name。<CURRENT_STOCK_CODE>时,“如果不考虑 TTM 呢”默认继续分析<CURRENT_STOCK_CODE>,不会切换到无关美股解释。stock_code与当前 context 不一致且用户未明确切换标的时,后端会拒绝、改写或要求澄清。Test Plan
_extract_stock_code()对TTM、PE TTM、YOY、QOQ、EBITDA等金融术语的排除。_extract_stock_code()对明确股票代码 fixture 的保留,确保正常美股 ticker 输入不被破坏。context.stock_code=<CURRENT_STOCK_CODE>时,用户追问“如果不考虑 TTM 呢”不会触发stock_code=TTM的工具调用。stock_code时,guard 能记录并阻断或改写。extractStockCodeFromMessage("如果不考虑 TTM 呢")返回 null。extractStockCodeFromMessage("分析 <US_STOCK_FIXTURE> 走势")仍返回该 fixture。activeStockCode。AGENT_CONTEXT_COMPRESSION_ENABLED后,结构化 active stock 仍被传递或注入。python -m pytest -m "not network"或受影响测试;前端改动执行cd apps/dsa-web && npm run lint && npm run build。docs/CHANGELOG.md的[Unreleased]仍使用扁平格式。Compatibility And Risk
_COMMON_WORDS,因为前端 UI 仍可能把指标缩写显示成当前股票。.env.example、Web 设置帮助、专题文档和 changelog。Non-goals