Skip to content

[Bug] 问股 Agent 追问上下文跑偏:TTM 等金融缩写被误识别为新股票标的 #1596

Description

@massif-01

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 预解析路径已有一部分金融术语黑名单,但没有系统覆盖 TTMLTMNTMFWDYOYQOQYTDEBITDADCFCAGR 等常见财务缩写。

目标不是只把 TTM 加进黑名单,而是建立一套“金融术语消歧 + 会话级当前标的锁定 + 工具参数护栏”的稳定机制。用户在同一问股会话里追问“如果不考虑 TTM”“PE 怎么看”“MACD 还没金叉吗”“不算回购呢”时,默认都应继续围绕当前股票;只有用户明确表达“换成 / 分析 / 看看 / 比较 + 新代码或新名称”时才切换标的。

How This Was Found

这个问题来自真实桌面端交互,不是代码审查里预设出来的。

复现场景如下:

  1. 在桌面端 问股 - DSA 页面打开一个已有个股分析对话。
  2. 首轮用户问题明确包含 <CURRENT_STOCK><CURRENT_STOCK_CODE>,并询问是否可以建仓。
  3. Agent 首轮回答正常围绕当前标的,技术面、基本面和估值段落都在讨论 <CURRENT_STOCK>
  4. 用户选中了回答中关于 TTM 估值口径、年化利润推算和回购约束的一段文字。
  5. 用户追问“如果不考虑 TTM 呢”。结合选中文本和前文语义,这里的 TTM 只能理解为市盈率口径,不是新标的。
  6. Agent 却输出了“关于 TTM / 某个无关美股标的”的分析,并围绕完全不同的公司和市场做判断。
  7. 用户指出“你搞错了,我说的是刚才那个标的”。Agent 随后回到原标的,但这说明系统已经发生过错误标的切换。
  8. 用户再次以“如果不考虑他的 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_codestock_nameprevious_analysis_summaryprevious_strategyprevious_priceprevious_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.pychat() 构造 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.pyanalysis_tools.pysearch_tools.pybacktest_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 已过滤一批金融缩写,例如 ETFIPORSIEPSPEGROEROAMACDVWAPBOLL
  • _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

根因是三个条件叠加:

  1. 当前标的没有被建模成会话级结构化状态。首轮 follow-up context 是一次性的,后续追问没有稳定携带当前 stock context。
  2. 股票代码识别规则过宽。裸 2-5 位大写英文被当成美股 ticker,但金融场景中大量指标缩写也是 2-5 位大写英文。
  3. 工具调用缺少最终护栏。模型生成的 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

  • [Phase 1] TTMLTMNTMFWDYOYQOQYTDEBITDADCFCAGR 等金融术语不会被前端或后端当作新股票代码。
  • [Phase 1] extractStockCodeFromMessage("如果不考虑 TTM 呢") 返回 null。
  • [Phase 1] _extract_stock_code("市盈率 TTM 怎么看") 返回空字符串。
  • [Phase 1] 明确的美股 ticker 输入能力不被破坏。测试应使用非敏感 fixture,或在测试注释中明确这是股票代码场景。
  • [Phase 2] 从历史报告进入问股后,第二轮及以后追问仍会携带当前 stock_code / stock_name
  • [Phase 2] 当前会话已有 <CURRENT_STOCK_CODE> 时,“如果不考虑 TTM 呢”默认继续分析 <CURRENT_STOCK_CODE>,不会切换到无关美股解释。
  • [Phase 2] 用户明确输入“换成 <NEW_STOCK_CODE> 看看”时,可以切换标的。
  • [Phase 2] 当模型 tool call 请求的 stock_code 与当前 context 不一致且用户未明确切换标的时,后端会拒绝、改写或要求澄清。
  • [Phase 2] tool guard 行为进入日志、diagnostics 或 tool_calls_log,便于复盘。
  • [Phase 2] 开启上下文压缩后,当前 active stock 不会只依赖摘要文本保存。
  • [全局] 修复不降低现有 A 股、港股、美股正常股票代码识别能力。
  • [全局] 修复不引入 broad fallback 或静默吞错;无法判断时应澄清或保守拒绝切换。
  • [全局] 公开 issue、PR body、测试名和截图说明不包含用户真实当前标的名称或代码。

Test Plan

  • Backend unit tests:覆盖 _extract_stock_code()TTMPE TTMYOYQOQEBITDA 等金融术语的排除。
  • 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 路径。
  • 不改变普通个股分析、每日分析、大盘复盘、告警中心和持仓页的默认行为。

Metadata

Metadata

Assignees

Labels

aibugSomething isn't workingsize/M

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions