-
Notifications
You must be signed in to change notification settings - Fork 0
ReAct
这篇文档讲的是:怎么让大模型不只是「嘴上说说」,而是能真正动手干活——查实时天气、查股价、调一个外部 API。
要讲清这件事,得分两层:先讲「工具调用(Tool Call)」这套最朴素的闭环——模型怎么发一张「指令便签」给外部程序去执行、再把结果收回来;再讲「ReAct」怎么把「思考」和「行动」交替循环,让模型能搞定那种需要好几步、中间还得看情况调整的复杂任务。
可以把它想成做一道数学大题:先想「这题该用什么方法」(思考),再「列式算一步」(行动),算完「看看结果对不对、还差什么」(观察),再决定下一步——如此循环,直到写出最终答案。大模型自己查不了今天的天气,但它会写一张小纸条递给「机械臂」,机械臂干完把结果递回来,它看了纸条再决定下一步该干什么。ReAct 就是把这套「想一步 → 干一步 → 看一眼 → 再想」不停循环,直到任务完成的套路,交给大模型自动跑完。
学界把这套思路叫 ReAct(Reasoning and Acting,推理与行动),由 Yao 等 2022 提出;本仓库在 LangGraph 与 CLI 两侧都按这个模板落地。
一次靠谱的「让模型动手干活」,至少要过四关:
- 给得出说明书:模型得先知道「有哪些工具、每个干什么」;
-
会下指令:模型决定该调哪个工具、传什么参数,并以结构化(通常是
JSON)的形式吐出来; -
外部代劳:真正去查天气 / 调
API的活,在模型外面、你的代码里跑; - 结果回灌:把执行结果喂回模型,它结合结果组织出人类看得懂的回答。
这就是最朴素的工具调用闭环:模型决策 → 外部执行 → 模型总结。网上多数科普到这一步就停了——它解决的是「一步就能搞定的活」:问天气,调一次 get_weather,总结,结束。
可现实任务常常不是一步到位的。比如「帮我分析一下平安银行这只股」——得先解析股票名(可能重名,得问用户选哪个)、再拉行情、再看月线、中间某步报错了还得换工具重试。一步闭环根本兜不住。ReAct 干的事,就是在这套四步闭环之上,加一个条件循环:观察完不结束,回到模型再想一步,直到模型认为「我能给最终答案了」为止。
所以整条链路是这样长的——左侧是四步闭环骨架,右侧是 ReAct 加出来的循环:
用户问题
→ 思考(Thought) 【模型】读历史消息,决定下一步调不调工具、调哪个
→ 行动(Action) 【模型】吐出结构化指令(tool_calls: 工具名 + 参数)
→ 执行(Execute) 【外部】代码真正去查天气/调 API
→ 观察(Observation) 【外部】结果回写进消息流,下一轮模型可见
→ 下一轮思考 【ReAct 循环】回到 Thought,直到模型不再发起调用
→ 最终答案(Final Answer)
为什么需要 ReAct,而不是只「纯思考」或只「纯行动」?
在 ReAct 出现之前,大模型的应用大致分两派,各有致命短板:
-
纯推理派(如思维链
CoT):模型在脑子里一步步推导,但碰不到外部实时信息——今天的天气、最新的股价,训练数据里根本没有,只能闭门造车,一本正经地胡说(幻觉)。 -
纯行动派(早期动作生成):模型直接去调
API,但缺深思熟虑的引导,任务稍微复杂就盲目乱撞,不会把多个工具串成一条线。
ReAct 把两者焊在一起:「思考」决定下一步干什么、怎么分析拿到的数据;「行动」负责从外部世界把真实数据捞回来。两者交替,形成一个会自我纠错的闭环。
把链路记成「思考 → 行动 → 观察 → 再思考」最省事。下表只作导航——跳到哪一节,源码摘录就在那一节里。
| 环节 | 详见 |
|---|---|
| 注册工具(给说明书) | §2.1 |
思考与决策(Thought) |
§2.2 |
行动与执行(Action) |
§2.3 |
观察与回灌(Observation) |
§2.4 |
刹车与循环(shouldContinue) |
§2.5 |
| 人在回路(缺信息 / 危险操作) | §2.6 |
痛点:模型并不知道你手里有哪些工具、每个工具什么时候该用。你不交代清楚,它要么乱调,要么干脆不调。
方案:发起对话时,把可用工具连同说明书一起发给模型。每份说明书长这样——
-
名字(
name):模型用它来「点名」; -
描述(
description):这是最关键的一行——模型靠读这段文字来判断「这工具干嘛用的、什么时候该用它」; -
参数表(
schema):每个参数叫什么、什么类型、什么含义。
描述写得好不好,直接决定模型会不会调对工具。比如「根据城市名称查询当前真实天气」就比「查询天气」强——前者点明了输入是城市名、输出是实况,模型才不会拿经纬度去喂它。
注册时把工具列表绑到模型上(bindTools),之后每次对话模型都带着这份说明书:
// packages/graph/src/weatherGraph.ts(节选)
const getWeatherTool = tool(
async ({ location }) => {
try { return await openMeteo.fetchWeatherByCity(location) }
catch (err) {
const message = err instanceof Error ? err.message : String(err)
return `查询「${location}」天气失败:${message}`
}
},
{
name: 'get_weather',
description: '根据城市名称查询当前真实天气(Open-Meteo 地理编码 + 预报)。',
schema: z.object({ location: z.string().describe('城市名称,如:北京、上海、Tokyo') }),
},
)
const llmWithTools = llm.bindTools(tools)CLI 侧没有 LangGraph 那种 bindTools,而是把工具契约统一成 ToolDef,对话开始时把 schema 列表递给驱动器:
// packages/cli/src/core/agent-loop.ts(节选)
const schemas = tools.map(t => t.schema) // 注册给 LLM 的 function 定义
const result = yield* chat(llmMessages, schemas.length > 0 ? schemas : undefined)ToolDef 比 LangGraph 工具多一个 risk 字段(safe / sensitive / destructive),用来标记「这个操作危不危险」,后面 §2.6 会用到。
痛点:拿到用户问题后,模型可能需要调工具,也可能根本不需要(比如纯闲聊)。它得自己判断「下一步要不要动手、动哪个」。
方案:让模型读一遍完整的对话历史(包括之前几轮的工具结果),再决定本轮怎么回。这一步就是 ReAct 里的「思考」节点。
LangGraph 里它叫 agent / chatbot 节点:首轮注入一句系统提示(引导模型缺信息时主动问用户、而不是瞎猜),然后带着工具绑定的模型跑一次:
// packages/graph/src/weatherGraph.ts — chatbot 节点(节选)
async function chatbot(state: typeof WeatherState.State) {
// 首轮注入 system prompt:引导 AI 缺信息时调 ask_* 而非臆测
const messages = state.messages[0]?.type === 'system'
? state.messages
: [new SystemMessage(ASK_SYSTEM_PROMPT), ...state.messages]
const response = await llmWithTools.invoke(messages)
return { messages: [response] }
}CLI 侧对应的 chat() 更直观一点:流式调用驱动器,模型一边吐字一边把 delta 推到终端——也就是说,观察还没回来之前,思考过程就已经对用户可见了;冻结进历史的,以驱动器返回的完整结果为准:
// packages/cli/src/core/agent-effects.ts(节选)
const result = yield* Effect.promise(() =>
driver.chat(messages, tools, (event) => {
if (event.type === 'text_delta')
ui.streaming.append(event.content) // 边吐字边渲染
}),
)
ui.streaming.commit()
return result对比:为什么不直接让模型「一次输出最终答案」?
因为很多答案模型自己并不知道——今天的天气、最新的股价,不在它的训练数据里。硬让它一次答完,它只会编。ReAct 让模型先吐「我想调某个工具」这个意图(结构化指令,而非最终文本),把「知道什么」和「能查到什么」分开:模型只负责想「该查什么」,外部负责真去查。这一步分开,是后面循环能跑起来的前提。
痛点:模型只会「说」不会「做」。它吐出的是一张「我想调 get_weather({ location: '北京' })」的便签,便签本身查不了天气。
方案:这张便签由模型外面的代码接住,真正去执行。执行这一段跟大模型一点关系都没有——就是普通的函数调用 / API 请求。
LangGraph 用一个现成的 ToolNode 干这件事:它解析上一条消息里的 tool_calls,按名字找到对应工具执行,把结果包成一条 ToolMessage:
// packages/graph/src/weatherGraph.ts — 图装配(节选)
.addNode('tools', new ToolNode(tools))
.addEdge('tools', 'agent') // 执行完固定回到 agentCLI 侧没有 ToolNode,等价的逻辑写在 handleToolCall 里,而且多了一道权限闸门——执行前先看这工具危不危险:
// packages/cli/src/core/agent-loop.ts — handleToolCall(节选)
const tool = tools.find(t => t.schema.function.name === name)
// 权限闸门:risk≠safe 触发确认;用户取消则跳过执行
if (tool.risk && tool.risk !== 'safe') {
const ok = yield* defaultConfirmGate(name, args)
if (!ok) {
const msg = '用户拒绝执行'
llmMessages.push({ role: 'tool', tool_call_id: tc.id, content: msg })
return
}
}
yield* setSpinner(`正在执行 ${name}...`)
const result = yield* tool.execute(args) // Effect,error channel 为 never,无需 catch
llmMessages.push({ role: 'tool', tool_call_id: tc.id, content: result })对比:为什么不干脆让模型自己执行代码?
让一个会胡说的模型直接在你的服务器上跑代码,太危险——它可能删错文件、调错接口、泄露密钥。把「决策」和「执行」切开,模型只签发指令,执行权攥在你代码手里,你才好在中间夹一道确认、一层沙箱、一次日志。这是 ReAct 落地时绕不开的安全考量。
痛点:执行完的结果,模型看不到就等于白干——它没法基于「刚才查到 22 度」去组织回答。
方案:把执行结果以一个新角色追加回对话历史。在 LangGraph 里是 ToolMessage;在 OpenAI 协议里是 { role: 'tool', ... } 消息。模型下一轮读历史时,这条结果就跟用户的提问、它自己的指令一样,是上下文的一部分。
状态通道也顺理成章:LangGraph 不为「思考」「观察」单开字段,而是把 HumanMessage、AIMessage(含 tool_calls)、ToolMessage 全往 messages 一条通道里追加;CLI 侧同理,llmMessages 这一条数组就是 LLM 看到的真相:
// 状态:只用 messages 一条通道
const WeatherState = Annotation.Root({
messages: Annotation<BaseMessage[]>({
reducer: (x, y) => x.concat(y),
default: () => [],
}),
})对比:为什么「思考」和「观察」要混在同一条消息流里,而不是分开存?
因为模型一次 invoke 时,看到的就是这整段历史。分开存还得每次重新拼装、还容易拼错顺序;混在一条流里,最新的结果天然排在末尾,模型「读完上文再决定下一步」这件事就免费实现了。ReAct 循环的本质,就是这条不断变长的消息流。
痛点:观察回来之后,是该再想一步,还是该结束?没有刹车,循环要么无限转,要么转一次就停。
方案:看模型这轮吐没吐 tool_calls——吐了,说明它还想动手,去执行;没吐,说明它已经能给最终答案了,结束。
LangGraph 里这道刹车是一条条件边:
// packages/graph/src/weatherGraph.ts — shouldContinue
function shouldContinue(state: typeof WeatherState.State): 'tools' | '__end__' {
const lastMessage = state.messages.at(-1)
if (lastMessage && AIMessage.isInstance(lastMessage) && lastMessage.tool_calls?.length)
return 'tools' // 有 tool call → 去执行
return '__end__' // 否则 → 结束
}CLI 侧没有「边」这东西,等价的判断就落在 while 循环顶上——看模型这轮吐没吐 tool_calls,吐了接着转,没吐就 return:
// packages/cli/src/core/agent-loop.ts — 刹车(节选)
const toolCalls = toolCallsOf(result)
if (toolCalls.length === 0)
return // 无 tool call → 本轮用户输入结束
for (const tc of toolCalls)
yield* handleToolCall(tc, tools, llmMessages)
// 带 tool 结果再次回到 while 顶部 chat —— 这就是 agent → tools → agent → …完整的 agentLoop(含入队用户消息、助手文本投影)见 §3.2。
对比:为什么不用「固定轮数」刹车(比如最多转 5 次)?
因为任务该转几轮,事先根本不知道:简单问题 1 轮够,复杂分析可能 3、5 轮。固定上限要么卡死在半路(任务没完成就硬结束),要么白等。「模型这轮还要不要动手」本身,就是最准的结束信号——它不发起调用了,自然就是觉得能答了。所以刹车挂在「意图」上,而不是挂在计时器上。
痛点:循环跑起来后,两类情况模型自己兜不住——
- 缺信息:用户说「查天气」却没说哪个城市。模型要是硬猜一个城市,就是幻觉的温床。
- 危险操作:比如要发一封邮件、删一条记录。让模型直接干,出事没人拦得住。
方案:在这两处把控制权暂时交还给用户——把循环挂起,等用户补一句、点个确认,再继续跑。这就是「人在回路」(HITL)。
LangGraph 侧用 interrupt() 把整张图暂停(同时落盘一个 checkpoint,进程重启都能续上):缺名称/代码时弹一个输入框让用户补,多匹配时弹一个选择列表让用户挑。完整的 resolve_stock 工具怎么用 interrupt 实现「缺名 → 弹输入框 / 多匹配 → 弹选择列表」,见 §五的 tushareGraph 示例。
CLI 侧没有 checkpoint,改用 Effect.async 把循环挂起在进程里:工具执行体里 yield* interact(...) 转出控制权,等用户回应后接着跑。挂起点都放在工具执行前,语义一致:
| 机制 |
LangGraph 侧 |
CLI 侧 |
|---|---|---|
| 缺信息,向用户索取 |
ask_* 工具内 interrupt()
|
ask_* 工具内 yield* interact(...)
|
| 危险操作,强制确认 | 工具 risk≠safe 时……(按需走 interrupt) |
defaultConfirmGate(risk≠safe 自动触发) |
| 挂起方式 | 暂停整图 + checkpoint 落盘 |
Effect.async 挂起,进程内,无持久化 |
interrupt 和 interact 两套机制形状一致(中断 payload 都是 { type: 'input' | 'select' | … }),底层由流式层适配成同一种前端「打断」事件——所以「问用户」这件事,三条实现路径(LangGraph 工具、CLI 工具、CLI 闸门)共用一套 UI 协议,没有重复造轮子。
前面讲「为什么」和「每个环节怎么做」,这里把整条流水线长什么样画出来。
LangGraph 的经典结构就是一个两节点环:agent 负责 Thought,tools 负责 Action 并回灌 Observation,靠条件边在两者间循环,直到 agent 决定走向 END。
flowchart TD
S([__start__]) --> AGENT
AGENT["agent: 读历史消息<br/>决定是否调工具、调哪个"] --> CK{"末条 AIMessage<br/>有 tool_calls ?"}
CK -->|有| TOOLS["tools: 执行 tool call<br/>结果写 ToolMessage 回灌"]
TOOLS --> AGENT
CK -->|无| E(["__end__: 最终答复"])
| 图节点 |
ReAct 角色 |
含义 |
|---|---|---|
__start__ |
输入 | 用户问题进入图 |
agent |
Thought |
读历史消息,决定是否调用工具、调哪个 |
tools |
Action + Observation
|
执行 tool call,结果写成 ToolMessage 回灌 |
__end__ |
Final Answer |
模型不再发起调用,给出最终答复,结束 |
CLI 端不依赖 LangGraph,用 Effect 编排出同一个循环:chat() = agent 节点,handleToolCall() = tools 节点,while 顶上的判空 = shouldContinue。
flowchart LR
subgraph loop [agentLoop while true]
chat["chat() — Agent"]
check{"tool_calls?"}
tools["handleToolCall() — Tools"]
endNode["return"]
end
chat --> check
check -->|否| endNode
check -->|是| tools
tools --> chat
整张图落到代码上,就是 agentLoop——它是 CLI 侧 ReAct 的「整图」,把上图的四个节点串成一条可跑的 Effect。读这段代码时,把 pushUser / pushAssistant 理解成往终端「投影」历史(与喂给 LLM 的真相分离),llmMessages.push 才是模型真正看到的上下文;while (true) 就是条件环,return 就是刹车:
// packages/cli/src/core/agent-loop.ts
export function agentLoop(userText: string, tools: ToolDef[], llmMessages: ChatCompletionMessageParam[]) {
return Effect.gen(function* () {
llmMessages.push({ role: 'user', content: userText })
yield* pushUser(userText)
const schemas = tools.map(t => t.schema)
while (true) {
const result = yield* chat(llmMessages, schemas.length > 0 ? schemas : undefined)
llmMessages.push(result)
const content = contentOf(result)
if (content)
yield* pushAssistant(content)
const toolCalls = toolCallsOf(result)
if (toolCalls.length === 0)
return
for (const tc of toolCalls)
yield* handleToolCall(tc, tools, llmMessages)
// 带 tool 结果再次 chat(react)
}
})
}逐行对照 ReAct 三拍:入队用户消息 → 进环;chat() 是 Thought(顺带把思考过程流式投影给用户);toolCallsOf 为空就 return,否则 handleToolCall 跑 Action 并把 Observation 写回 llmMessages;环顶再 chat,即「观察完回到思考」。
两侧形态不同(图 + 条件边 / Effect + while),语义一致:有 tool call 就去执行、否则结束;执行结果回灌后回到思考。
工具执行到一半需要用户介入时,循环挂起而不是结束;用户回应后从挂起点续跑,ToolNode 把工具返回值包成 Observation 再回 agent。
flowchart TD
A[agent: 决定调 resolve_stock] --> T[tools: 执行]
T -->|缺信息/多匹配| IN[interrupt 暂停图]
IN -->|用户补全/选择| R[Command resume 续跑]
R --> T
T -->|拿到结果| O[ToolMessage 回灌]
O --> A
从用户问一句「北京今天天气怎么样」,到屏幕上出现流式回答,大致按下面这条时间线走。可以把 Client / Server / LangGraph 理解成「前台点菜 → 后厨总控 → 各工位出锅」。这里特意挑了一个缺信息的例子——模型第一轮先 ask_input 问城市,第二轮才真正查天气,正好把 ReAct 的多轮循环和人在回路都走一遍。
sequenceDiagram
autonumber
participant U as Client
participant S as Server
participant G as LangGraph
participant A as agent
participant T as tools
U->>S: 用户问题「查天气」(缺城市)
S->>G: streamEvents + state
G->>A: agent 读取消息
A-->>G: AIMessage + tool_calls: ask_input
G->>T: tools 执行 ask_input
T-->>G: interrupt 暂停(等用户补城市)
G-->>S: 中断事件
S-->>U: 弹出输入框
U->>S: 用户输入「北京」
S->>G: Command({ resume })
G->>T: 续跑 ask_input
T-->>G: ToolMessage「用户回答: 北京」
G->>A: agent 读 Observation
A-->>G: AIMessage + tool_calls: get_weather
G->>T: tools 执行 get_weather
T-->>G: ToolMessage(Open-Meteo 实况)
G->>A: agent 读实况
loop text-delta
A-->>G: 流式文本
G-->>S: agui 事件
S-->>U: 终端渲染
end
A-->>G: AIMessage(无 tool_calls)最终答复
G-->>S: 结束
S-->>U: 历史冻结
整条时间线,其实就是把 §一的链路逐拍展开:思考 → 行动 → 执行 → 观察 → 再思考,循环两轮,第二轮不再发起调用,于是刹车触发、给出最终答案。ReAct 说的「让模型一边想一边干」,落到代码上,就是这么一个会自己转、会中途停下来问你、问完接着转的环。
前面几节都在讲「ReAct 的每个环节是什么、为什么」,这一节把一个真实在跑的图完整摊开——对应 packages/graph/src/tushareGraph.ts,做 A 股个股分析。它的 agent ↔ tools 环和最简形态完全一样(agent + tools + shouldContinue),区别全在「工具从哪来」和「缺信息时怎么停下来问用户」两处。读懂这个例子,就等于把前面讲的 Thought / Action / Observation / shouldContinue / 人在回路,在一套真实代码里全走了一遍。
-
工具来源是远程
MCP:Tushare 的股票数据工具由https://api.tushare.pro/mcp提供,需要异步建连、listTools拉取,无法像getWeatherTool那样在模块顶层new出来。 -
懒加载单例:
getTushareToolset()维护模块级toolsetPromise,首次调用才createTushareMcp().then(buildTushareToolset);建连失败则重置promise允许重试。 -
resolve_stock用interrupt()实现人在回路:缺名称/代码时弹input中断,多匹配时弹select中断,复用ASK_TOOLS的中断协议形状。
packages/graph/src/tools/mcpToLangchain.ts 把远程 MCP 工具适配成 LangChain DynamicStructuredTool,供 ToolNode 调度:
export function mcpToolToLangchainTool(
tool: McpTool,
callTool: TushareMcp['callTool'],
): DynamicStructuredTool {
return new DynamicStructuredTool({
name: tool.name,
description: tool.description ?? `Tushare MCP 工具: ${tool.name}`,
schema: tool.inputSchema, // MCP inputSchema 本身就是 JSON Schema,直接透传
func: async (input) => {
try {
return await callTool(tool.name, input as Record<string, unknown>)
}
catch (err) {
return toolErrorMessage(err) // 错误消化为字符串,避免 ToolNode 抛出中断流
}
},
})
}MCP inputSchema 本身就是 JSON Schema,langchain 1.2+ 的 DynamicStructuredTool.schema 接受 JsonSchema7Type,bindTools 时原样转成 OpenAI function。与 CLI 侧 mcpToolsToToolDefs(产出 Effect ToolDef)对应,但这里产出 langchain 工具供 ToolNode 调度。callTool 由 tushareClient.ts 提供,内部统一打请求日志(发起时间/参数/超时/耗时)。
packages/tools/src/mcp/tushareClient.ts 是 CLI 与 graph 共用的 Tushare MCP 客户端,导出 createTushareMcp() 与 TOKEN_HINT:
-
传输层回退:先尝试
StreamableHTTPClientTransport,失败回退到SSEClientTransport;两者都带Authorization: Bearer <token>+X-Tushare-Token头。 -
统一超时:
MCP_OP_TIMEOUT_MS = 15_000,withTimeout包裹connect/listTools,避免 tushare 服务偶发不响应时永久hang;超时即reject,上层toolsetPromise重置后可重试。 -
结果格式化:
formatCallToolResult把MCPcontent数组(text / resource / image / audio)拼成纯字符串,isError时前缀「错误:」。 -
日志:
listTools与每次callTool都打印[tushare-mcp]发起/完成/失败日志(含 ts、elapsed、args 前 200 字符、isError、返回长度)。
packages/tools/src/mcp/stockResolve.ts 把「名称/代码 → 候选列表」的纯逻辑抽出来,CLI 与 graph 复用:
-
findStockBasicTool(tools)/findQueryTool(tools):在MCP工具列表里找stock_basic或通用查询工具(候选名sdk_call/tushare_query/get_api_query/query/call_api,或inputSchema含api_name的工具)。 -
buildStockBasicArgs(tool, name, ts_code):按工具形态构造参数——stock_basic工具给{list_status:'L', ts_code?, name?};通用查询工具给{api_name:'stock_basic', params: {...}}(paramsschema 为 string 时序列化为JSON字符串)。 -
parseStockCandidates(text):先尝试JSON.parse+extractRows(兼容data/items/rows/result数组及fields+items二维表),失败则正则兜底(\d{6}\.[A-Z]{2})\s+([^\n,|]+)。 -
queryStockBasic(mcp, queryTool, {name, ts_code}):组合以上三步,调一次MCP返回StockCandidate[]。
packages/tools/src/mcp/tusharePrompt.ts 读取同目录 prompts/tushare.md 模板(A 股个股分析框架:量价 + 月线分析、MCP 工具使用规则),用 renderPrompt 把 ${to_day} 占位替换为当日 YYYYMMDD,进程级渲染一次导出为 TUSHARE_SYSTEM_PROMPT(单一来源,CLI 与 graph 复用)。
tushareGraph.ts 内 createResolveStockTool(mcp) 构造一个特殊的 LangChain tool,内部用 interrupt() 暂停图——这就是 §2.6 讲的「缺信息时停下来问用户」的完整落地:
return tool(
async ({ ts_code, name }) => {
let code = ts_code
let nm = name
// 缺名称/代码 → interrupt(input) 让用户补全
if (!code && !nm) {
const resp = interrupt<{ type:'input', message:string, placeholder?:string }, { value:string }>({
type: 'input',
message: '请输入股票名称或代码:',
placeholder: '平安银行 / 000001.SZ',
})
const input = resp.value.trim()
if (input.includes('.')) code = input
else nm = input
}
if (code) return JSON.stringify({ ts_code: code, name: nm ?? null }, null, 2)
if (!nm) return TOKEN_HINT
if (!stockBasicTool) return '未找到 Tushare MCP stock_basic 工具,无法解析股票名称'
// 名称 → 候选列表 → 多匹配 interrupt(select)
let stocks: StockCandidate[]
try { stocks = await queryStockBasic(mcp, stockBasicTool, { name: nm }) }
catch (err) { return toolErrorMessage(err) }
const picked = await pickStock(stocks) // 0 候选返回 null;1 候选直返;多候选 interrupt(select)
if (!picked) return stocks.length === 0 ? `未找到名称「${nm}」对应的股票` : '未选择股票'
return JSON.stringify({ ts_code: picked.ts_code, name: picked.name }, null, 2)
},
{
name: 'resolve_stock',
description: '解析股票名称或代码为 ts_code。用户只给简称/模糊名称时必须先调用此工具;多匹配时弹出选择列表。',
schema: z.object({
ts_code: z.string().optional().describe('TS 代码,如 000001.SZ'),
name: z.string().optional().describe('股票名称,支持模糊匹配'),
}),
},
)读这段时把它对回 ReAct 的循环:模型吐 tool_calls: resolve_stock 是 Action;执行体里两次 interrupt 是「工具跑到一半发现缺信息,把图挂起问用户」,用户回应后 Command({resume}) 续跑,工具返回的 JSON 字符串就是 Observation,回灌后模型接着想下一步。pickStock 封装多匹配中断:0 候选返回 null,1 候选直返,多候选 interrupt({type:'select', options}),外部 Command({resume:{value}}) 喂回用户所选 ts_code。中断 payload 形状({type:'select', message, options} / {type:'input', message, placeholder})与 ASK_TOOLS 内联类型一致,由 stream 层 mapInterruptPayloadToAgUi 适配为 AG-UI Interrupt。
async function buildTushareToolset(mcp: TushareMcp): Promise<TushareToolset> {
const tools = [
...mcpToolsToLangchainTools(mcp), // 远程 MCP 工具 → DynamicStructuredTool
createResolveStockTool(mcp), // 本地 resolve_stock(含 interrupt)
...ASK_TOOLS, // ask_input/ask_choice/ask_multi_choice/ask_confirm
]
const llm = new ChatOpenAI({ model: process.env.OPENAI_MODEL ?? '', temperature: 0 })
const llmWithTools = llm.bindTools(tools)
const toolNode = new ToolNode(tools)
return { tools, llmWithTools, toolNode }
}
let toolsetPromise: Promise<TushareToolset> | null = null
async function getTushareToolset(): Promise<TushareToolset> {
if (!toolsetPromise) {
toolsetPromise = createTushareMcp().then(buildTushareToolset)
toolsetPromise.catch(() => { toolsetPromise = null }) // 失败重置,允许重试
}
return toolsetPromise
}懒加载是为了避免模块加载期强依赖 TUSHARE_TOKEN(MCP 需异步建连,与 weatherGraph 顶层构造不同)。
async function agent(state: typeof TushareState.State) {
const { llmWithTools } = await getTushareToolset()
const messages = state.messages[0]?.getType() === 'system'
? state.messages
: [new SystemMessage(`${TUSHARE_SYSTEM_PROMPT}\n\n${ASK_SYSTEM_PROMPT}`), ...state.messages]
const response = await llmWithTools.invoke(messages)
return { messages: [fixMisplacedToolCalls(response)] }
}
async function toolsNode(state: typeof TushareState.State) {
const { toolNode } = await getTushareToolset()
// ToolNode 透传工具内 interrupt(GraphInterrupt),图暂停 + checkpoint
return toolNode.invoke(state)
}- 首轮注入
TUSHARE_SYSTEM_PROMPT+ASK_SYSTEM_PROMPT(拼接),resume续跑时已含则跳过。 -
toolsNode用ToolNode,它原生识别工具内抛出的GraphInterrupt并向上抛,图暂停 +checkpoint落盘,等外部Command({resume})恢复。
agent 节点在 invoke 后会对响应跑一遍 fixMisplacedToolCalls(response)。背景:langchain 1.4.7 在 streamEvents(stream 模式)下聚合 deepseek-v4-flash 的 stream chunks 时,会把 tool_call 错误塞进 content 数组(元素含 name/args/id 但 type 标为 text),而 tool_calls 字段为空 → shouldContinue 误判不调工具。该函数从 content 数组提取误放的 tool_call 重建 tool_calls,并剥离 name/args 只留纯 text 块;v4-pro 等正常模型(tool_calls 已有值或 content 非数组)直接跳过。
export const tushareGraph = new StateGraph(TushareState)
.addNode('agent', agent)
.addNode('tools', toolsNode)
.addEdge('__start__', 'agent')
.addConditionalEdges('agent', shouldContinue)
.addEdge('tools', 'agent')shouldContinue 与 weather 图完全相同:末条 AIMessage 有 tool_calls → 'tools',否则 '__end__'。中断(resolve_stock / ask_*)发生时图暂停,不进 shouldContinue;resume 后从中断点继续执行工具,ToolNode 把工具返回字符串包成 ToolMessage,再回 agent。
tushareGraph.test.ts 两个用例:
-
可编译:
tushareGraph.compile({ checkpointer: new MemorySaver() })不抛(验证懒加载MCP,不依赖TUSHARE_TOKEN)。 -
resolve_stock多匹配interrupt→resumeround-trip:mockMCP的stock_basic返回两只候选(平安银行 / 中国平安),喂resolve_stock({name:'平安'})触发select中断;app.getState断言snapshot.tasks[0].interrupts[0].value为{type:'select', options:长度2};再app.stream(new Command({resume:{value:'000001.SZ'}})),断言末条ToolMessage含000001.SZ+平安银行,snapshot.next为空(图结束)。