第 3 章 · 数据层、厂商路由与防幻觉
本章讲数据从哪来、怎么在多个厂商间路由、以及框架花了最大力气做的一件事:不让 LLM 凭空编数字。这是整个项目工程含量最高的一支。
3.1 全景:工具、厂商、路由三层
agent 想要数据
│ 调工具 get_stock_data / get_news / get_fundamentals ...
▼
[抽象工具层] agent_utils.py 汇总所有工具的公开入口
│
▼
[厂商路由] interface.route_to_vendor(method, ...)
│ 按 category → 配置的厂商链 → 逐个试
▼
[厂商实现] yfinance / alpha_vantage / fred / polymarket
工具按类别组织(interface.py:36-78):核心行情、技术指标、基本面、新闻、宏观(FRED)、预测市场(Polymarket)。每个方法映射到多个厂商实现(VENDOR_METHODS, interface.py:95-144)。
3.2 机制一:厂商路由——显式链,失败要响
它要解决的小问题: 同一份数据可能有多个来源(yfinance、Alpha Vantage)。天真做法是“主厂商失败就自动换下一个”,但这带来一个隐患:用户明明只选了 yfinance,却拿到了 Alpha Vantage 的数据,导致跨厂商数字不一致。
本项目的取舍(#988/#289): 配置的厂商列表本身就是链,绝不 fallback 到你没选的厂商。 想要多厂商兜底就自己列,如 data_vendors="yfinance,alpha_vantage"。核心逻辑(interface.py:168):
route_to_vendor(method):
vendor_chain = 配置里显式列的厂商(过滤掉不支持该 method 的)
for vendor in vendor_chain:
try: return impl(*args)
except VendorRateLimitError: 记日志,试下一个
except VendorNotConfiguredError: 记第一个错,试下一个
except NoMarketDataError: 记为 last_no_data,试下一个
except Exception: 记日志+第一个错,试下一个 ← 绝不静默吞掉(#989)
两种收尾很讲究:
- 有厂商明确说“没数据” → 返回一个指导性哨兵字符串
NO_DATA_AVAILABLE: ...(interface.py:242-247),明确告诉 agent“这符号可能无效/退市/未覆盖/数据陈旧,别估别编,就报不可用”。这比返回空字符串强——空字符串会诱导 LLM 编一个价格。 - 可选类别(宏观、预测市场)出错 → 降级成
DATA_UNAVAILABLE哨兵而非中断整个 run(OPTIONAL_CATEGORIES,interface.py:92、interface.py:254-259)。核心数据(行情/基本面/新闻)出错则大声抛出,不许静默。
配置在 default_config.py:133-144:类别级 data_vendors + 工具级 tool_vendors(后者优先)。
3.3 机制二:符号归一化——先翻译成 Yahoo 的“方言”
它要解决的小问题: 用户/券商习惯写 XAUUSD(黄金)、EURUSD(外汇)、BTCUSD(加密)、SPX500(指数 CFD),但 Yahoo Finance 要的是 GC=F、EURUSD=X、BTC-USD、^GSPC。直接把券商符号丢给 Yahoo 会返回空——然后 LLM 就可能围绕空结果编个价格(issue #781)。
思路: 一个纯语法(无网络调用)的 normalize_symbol(symbol_utils.py:104),按优先级四步翻译:
1. 显式别名表(金属/能源/指数CFD): XAUUSD→GC=F, SPX500→^GSPC ...
2. 加密规则: 已知币种 + USD/USDT/USDC → BASE-USD
3. 外汇规则: 六字母且两半都是 ISO 币种 → PAIR=X
4. 都不匹配: 原样大写返回(普通股票/ETF/Yahoo 原生符号)
归一化在多处复用:拉行情、算 alpha 收益(trading_graph.py:272)、解析标的身份(agent_utils.py:96)——保证“定身份、拉价格、算收益”打的是同一个 instrument(issue #983/#984)。
3.4 机制三:标的身份——运行开始就钉死“这是哪家公司”
它要解决的小问题(issue #814): 没有 ground-truth 公司名时,市场分析师会把价格走势 pattern-match 成某个行业的叙事,然后编造一个身份,这个错误身份接着往下游所有 agent 传染。
思路: 运行一开始就用 yfinance 做一次确定性身份解析,注入所有 agent。
resolve_instrument_context(ticker, asset_type) ← trading_graph.py:336
│
├─ resolve_instrument_identity(ticker) ← agent_utils.py:79
│ yfinance 拉 longName/sector/industry/exchange,lru_cache 缓存
│ 失败就返回 {} —— fail-open,绝不因身份查不到就阻断分析
▼
build_instrument_context(...) ← agent_utils.py:122
生成一段话注入 state.instrument_context:
"要分析的是 `TICKER`,在每次工具调用/报告里都用这个精确符号...
已解析身份: 公司 X; 行业 Y/Z; 交易所 W。
除非工具结果明确推翻,不许换成别的公司或符号。"
这段 context 通过 get_instrument_context_from_state(agent_utils.py:172)被每个 agent 读取。fail-open 设计很关键:yfinance 限流/不认识都只返回 {},退化成“仅符号”的 context,绝不在分析开始前就挂掉。
3.5 机制四:核验快照——精确数字的唯一真值
它要解决的小问题(issue #830): 市场分析师会编精确数字——引用一个不存在的 Bollinger 带值、或“历史验证过的反弹位”,而底层数据根本不支持。
思路: 提供一个完全确定性、无 LLM 参与的核验快照工具 build_verified_market_snapshot(market_data_validator.py:62),算出:分析日当天或之前的最新 OHLCV 行、一组固定的常用指标、最近若干日收盘。渲染成 markdown 表交给分析师,并在 prompt 里命令它“把这当精确数字的唯一真值,别的工具冲突就报差异,不许自己调和”。
防前视(look-ahead)在这里是双保险:load_ohlcv 已经过滤未来行,核验路径又防御性地再切一次 df[df["Date"] <= curr_date](market_data_validator.py:41-44)——因为这是核验路径,不信任输入已被预过滤。
这个工具被绑进市场分析师的工具集且被 prompt 强制调用,所以它必须在图里可执行,否则调用失败模型会报“不可用”(trading_graph.py:196-201 的注释点明了这点)。
3.6 机制五:grounded 情绪分析——先喂真数据,再让它开口
它要解决的小问题(issue #557/#796): 老的“社交媒体分析师”prompt 要求分析社交媒体,但手里只有 Yahoo 新闻这一个工具——于是 LLM 在 prompt 压力下编造 Reddit/X/StockTwits 内容。
思路(sentiment_analyst.py:1-24 的模块注释说得很清楚): 改 名情绪分析师,不用 tool-calling,改成在调 LLM 前预取三个数据源塞进 prompt:
| 源 | 内容 | 取数 |
|---|---|---|
| 新闻 | Yahoo Finance 头条(机构视角) | get_news.func(...) |
| StockTwits | 散户帖 + 用户自标 Bullish/Bearish | fetch_stocktwits_messages |
| r/wallstreetbets、r/stocks、r/investing | fetch_reddit_posts |
每个 fetcher 优雅降级(出错返回占位字符串而非抛异常),所以 LLM 从第 0 轮就看到东西——要么真数据要么明确占位,没有“空着诱导它编”的机会。输出走结构化(SentimentReport:band + score + confidence + narrative,schemas.py:273)。
3.7 小结:四道防幻觉闸门
| 闸门 | 对付什么 | 关键符号 | issue |
|---|---|---|---|
| 符号归一化 | 券商符号→Yahoo,避免空结果诱导编价 | normalize_symbol | #781 |
| 身份解析 | 防止把价格走势编成错误公司 | resolve_instrument_identity | #814 |
| 核验快照 | 精确数字必须有据 | build_verified_market_snapshot | #830 |
| grounded 情绪 | 别编社交内容 | create_sentiment_analyst(预取数据) | #557/#796 |
| NO_DATA 哨兵 | 没数据就说没有,别估 | route_to_vendor 收尾 | #988/#289 |
3.8 代码地图(本章)
| 主题 | 文件 | 符号 |
|---|---|---|
| 厂商路由 | tradingagents/dataflows/interface.py | route_to_vendor、VENDOR_METHODS、OPTIONAL_CATEGORIES |
| 符号归一化 | tradingagents/dataflows/symbol_utils.py | normalize_symbol、crypto_base、_ALIASES |
| 核验快照 | tradingagents/dataflows/market_data_validator.py | build_verified_market_snapshot、_verified_rows |
| 标的身份 | tradingagents/agents/utils/agent_utils.py | resolve_instrument_identity、build_instrument_context |
| grounded 情绪 | tradingagents/agents/analysts/sentiment_analyst.py | create_sentiment_analyst |
| 错误类型 | tradingagents/dataflows/errors.py | NoMarketDataError、VendorRateLimitError、VendorNotConfiguredError |