跳到主要内容

第 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产出字段文件
分析市场分析师quickmarket_reportanalysts/market_analyst.py
分析情绪分析师quicksentiment_reportanalysts/sentiment_analyst.py
分析新闻分析师quicknews_reportanalysts/news_analyst.py
分析基本面分析师quickfundamentals_reportanalysts/fundamentals_analyst.py
辩论多头 / 空头研究员quickinvestment_debate_stateresearchers/bull_researcher.pybear_researcher.py
裁决研究经理deepinvestment_planmanagers/research_manager.py
执行交易员quicktrader_investment_plantrader/trader.py
风控激进 / 保守 / 中性分析师quickrisk_debate_staterisk_mgmt/*_debator.py
拍板组合经理deepfinal_trade_decisionmanagers/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”这么简单,它塞了三样硬约束:

  1. 一张指标菜单 + 用法说明。 列出 SMA/EMA/MACD/RSI/Bollinger/ATR/VWMA 等约十几个指标,每个配“干嘛用、什么时候骗人”,并要求最多选 8 个、避免冗余(别同时选 rsi 和 stochrsi)。
  2. 强制取数顺序。 必须先 get_stock_data 拉 CSV,再 get_indicators 按精确指标名算——名字写错工具调用会失败。
  3. 核验优先(防幻觉的核心一句): 写报告前必须调 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),只是维护三份 *_historylatest_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-102ResearchPlan 为例)。组合经理的 PortfolioDecisionrating(五档枚举)、executive_summaryinvestment_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.pycreate_bull_researcher
市场分析师 prompttradingagents/agents/analysts/market_analyst.pycreate_market_analyst
结构化输出封装tradingagents/agents/utils/structured.pybind_structuredinvoke_structured_or_freetext
全部 Schema + rendertradingagents/agents/schemas.pyPortfolioDecisionResearchPlanTraderProposalSentimentReportrender_*
五档评级tradingagents/agents/utils/rating.pyRATINGS_5_TIERparse_rating
消息清理占位符tradingagents/agents/utils/agent_utils.pycreate_msg_deleteget_language_instruction
组合经理 / 交易员tradingagents/agents/managers/portfolio_manager.pyagents/trader/trader.pycreate_portfolio_managercreate_trader