第 2 章 · Agent 角色、辩论与结构化输出
本章讲每个 agent 具体做什么、怎么写 prompt、辩论怎么一步步推进、结构化输出怎么保证下游拿到干净格式。
2.1 所有 agent 长一个样:闭包工厂
每个 agent 都由一个 create_<role>(llm) 函数造出来,返回一个接收 state、返回 state 更新 dict 的节点函数。这个模式全项目统一。以多头研究员为例(researchers/bull_researcher.py:7):
def create_bull_researcher(llm):
def bull_node(state) -> dict:
# 1. 从 state 读四份分析师报告 + 辩论历史
# 2. 拼 prompt,调 llm.invoke
# 3. 把回复追加进辩论历史,count+1,写回 state
return {"investment_debate_state": new_state}
return bull_node
好处:llm 通过闭包捕获,节点函数签名干净(只吃 state),GraphSetup 造节点时只管 create_*(llm)(setup.py:83-92)。
2.2 全体 agent 花名册
| 阶段 | Agent | 用哪个 LLM | 产出字段 | 文件 |
|---|---|---|---|---|
| 分析 | 市场分析师 | quick | market_report | analysts/market_analyst.py |
| 分析 | 情绪分析师 | quick | sentiment_report | analysts/sentiment_analyst.py |
| 分析 | 新闻分析师 | quick | news_report | analysts/news_analyst.py |
| 分析 | 基本面分析师 | quick | fundamentals_report | analysts/fundamentals_analyst.py |
| 辩论 | 多头 / 空头研究员 | quick | investment_debate_state | researchers/bull_researcher.py、bear_researcher.py |
| 裁决 | 研究经理 | deep | investment_plan | managers/research_manager.py |
| 执行 | 交易员 | quick | trader_investment_plan | trader/trader.py |
| 风控 | 激进 / 保守 / 中性分析师 | quick | risk_debate_state | risk_mgmt/*_debator.py |
| 拍板 | 组合经 理 | deep | final_trade_decision | managers/portfolio_manager.py |
两档 LLM 的分工: 框架配两个模型——quick_think_llm(便宜快,跑量大的分析/辩论)和 deep_think_llm(强,跑两个关键裁决:研究经理、组合经理)。在 TradingAgentsGraph.__init__ 里各建一个(trading_graph.py:101-115),GraphSetup 按角色分配(setup.py:83-92)。
2.3 分析师 prompt 的门道:市场分析师
市场分析师的 system prompt(market_analyst.py:24-56)不是“帮我分析 NVDA”这么简单,它塞了三样硬约束:
- 一张指标菜单 + 用法说明。 列出 SMA/EMA/MACD/RSI/Bollinger/ATR/VWMA 等约十几个指标,每个配“干嘛用、什么时候骗人”,并要求最多选 8 个、避免冗余(别同时选 rsi 和 stochrsi)。
- 强制取数顺序。 必须先
get_stock_data拉 CSV,再get_indicators按精确指标名算——名字写错工具调用会失败。 - 核验优先(防幻觉的核心一句): 写报告前必须调
get_verified_market_snapshot,并把它当任何精确数字的唯一真值;别的工具跟它冲突就报差异,不许自己编一个“调和后的数”(market_analyst.py:51)。
模型和工具通过 llm.bind_tools(tools) 绑定(market_analyst.py:81),配合第 1 章的取数循环反复调用。
2.4 辩论是怎么一步步推进的
辩论没有魔法,就是字符串累积。 每个研究员/风险 agent 干三件事:读历史 → 生成反驳 → 把自己这段追加进历史。多头研究员(bull_researcher.py:47-57):
response = llm.invoke(prompt) # prompt 里塞了 history + 对手上一句
argument = f"Bull Analyst: {response.content}"
new_state = {
"history": history + "\n" + argument, # 公共历史
"bull_history": bull_history + "\n" + argument, # 多头专属历史
"current_response": argument, # 供路由判断“刚谁说的”
"count": investment_debate_state["count"] + 1, # 节拍器 +1
}
关键点:
current_response带"Bull Analyst:"前缀——第 1 章的路由靠startswith("Bull")判断该换谁。- 对手的话通过 prompt 的
Last bear argument: {current_response}注入,所以是真·你来我往,不是各说各话。 - 风险三方结构同构(
risk_mgmt/aggressive_debator.py:43-55),只是维护三份*_history和latest_speaker。
2.5 结构化输出:让下游拿到干净字段
它要解决的小问题: 三个决策 agent(研究经理、交易员、组合经理)和情绪分析师的产出要被下游机器读(评级抽取、记忆日志、报告渲染)。如果全是自由散文,下游得写脆弱的正则,一换模型就漂。
思路: 用各家模型的原生结构化输出,让模型直接吐一个带类型的 Pydantic 对象,再用 render 函数转回统一 markdown。三 agent 共用一个封装(agents/utils/structured.py):
bind_structured(llm, Schema, name) ← 建 agent 时: llm.with_structured_output(Schema)
│ provider 不支持 → 返回 None,日志告警,全程走自由文本
▼
invoke_structured_or_freetext(...) ← 每次调用时:
│ 1. 结构化调用成功 → render(结果) 转 markdown
│ 2. 结果为 None(思考模型没调工具) → 抛错
│ 3. 任何异常 → 降级 plain_llm.invoke,返回 .content
真实实现(structured.py:49-79)的降级链保证流水线永不因格式问题卡死:结构化不行就退自由文本。
Schema 即指令: Pydantic 字段的 description 直接当模型的输出指令用,于是 prompt 正文只需给上下文和评级口径(agents/schemas.py:82-102 的 ResearchPlan 为例)。组合经理的 PortfolioDecision 有 rating(五档枚举)、executive_summary、investment_thesis、可选 price_target/time_horizon(schemas.py:188-224)。
render 保持向后兼容: render_pm_decision 把对象转回带 **Rating**: **Executive Summary**: 等固定表头的 markdown(schemas.py:231),因为记忆日志、CLI、报告写盘都在读这个形状。交易员的 render 还特意保留末行 FINAL TRANSACTION PROPOSAL: **BUY/HOLD/SELL**,兼容老代码里 grep 这行的停止信号(schemas.py:158-180)。
空值容错: LLM 常把可选数字字段填成 "None"/"N/A" 字符串,_coerce_optional_float 把这些“类空”值转成真 None,让结构化调用能过校验(schemas.py:30-37,issue #1058)。
2.6 五档评级:一套词汇,四处共用
评级是 Buy / Overweight / Hold / Underweight / Sell(rating.py:17)。研究经理、组合经理、信号处理、记忆日志都用它——集中在 rating.py 避免漂移。抽取用两趟启发式(parse_rating, rating.py:28):先找显式 Rating: X 标签(容忍 markdown 加粗),找不到再扫全文第一个评级词,都没有就默认 Hold。
交易员用的是更窄的三档 TraderAction(Buy/Hold/Sell)——它只负责“做不做这笔交易”,仓位轻重的 Overweight/Underweight 判断留给组合经理(schemas.py:54-65)。
2.7 一个踩过的坑:清理占位符不能是 “Continue”
每个分析师收工后的清理节点 create_msg_delete(agent_utils.py:190)把消息清空后,注入的占位消息特意不是裸 "Continue"——因为某些 OpenAI 兼容 provider 会把 “Continue” 当成真正的用户任务,去分析“continue 这个词”而不是分析标的(issue #888)。所以占位符锚定了标的上下文和日期:
placeholder = HumanMessage(content=(
f"Proceed with your assigned analysis for this workflow. "
f"{instrument_context} The analysis date is {trade_date}."))
2.8 代码地图(本章)
| 主题 | 文件 | 符号 |
|---|---|---|
| Agent 工厂模式 | tradingagents/agents/researchers/bull_researcher.py | create_bull_researcher |
| 市场分析师 prompt | tradingagents/agents/analysts/market_analyst.py | create_market_analyst |
| 结构化输出封装 | tradingagents/agents/utils/structured.py | bind_structured、invoke_structured_or_freetext |
| 全部 Schema + render | tradingagents/agents/schemas.py | PortfolioDecision、ResearchPlan、TraderProposal、SentimentReport、render_* |
| 五档评级 | tradingagents/agents/utils/rating.py | RATINGS_5_TIER、parse_rating |
| 消息清理占位符 | tradingagents/agents/utils/agent_utils.py | create_msg_delete、get_language_instruction |
| 组合经理 / 交易员 | tradingagents/agents/managers/portfolio_manager.py、agents/trader/trader.py | create_portfolio_manager、create_trader |