# `ReAct` 这篇文档讲的是:怎么让大模型不只是「嘴上说说」,而是能真正动手干活——查实时天气、查股价、调一个外部 `API`。 要讲清这件事,得分两层:先讲「**工具调用**(`Tool Call`)」这套最朴素的闭环——模型怎么发一张「指令便签」给外部程序去执行、再把结果收回来;再讲「`ReAct`」怎么把「思考」和「行动」交替循环,让模型能搞定那种需要好几步、中间还得看情况调整的复杂任务。 可以把它想成做一道数学大题:先想「这题该用什么方法」(思考),再「列式算一步」(行动),算完「看看结果对不对、还差什么」(观察),再决定下一步——如此循环,直到写出最终答案。大模型自己查不了今天的天气,但它会写一张小纸条递给「机械臂」,机械臂干完把结果递回来,它看了纸条再决定下一步该干什么。`ReAct` 就是把这套「想一步 → 干一步 → 看一眼 → 再想」不停循环,直到任务完成的套路,交给大模型自动跑完。 学界把这套思路叫 **`ReAct`**(Reasoning and Acting,推理与行动),由 [Yao 等 2022](https://arxiv.org/abs/2210.03629) 提出;本仓库在 `LangGraph` 与 `CLI` 两侧都按这个模板落地。 --- ## 一、整体链路 一次靠谱的「让模型动手干活」,至少要过四关: 1. **给得出说明书**:模型得先知道「有哪些工具、每个干什么」; 2. **会下指令**:模型决定该调哪个工具、传什么参数,并以结构化(通常是 `JSON`)的形式吐出来; 3. **外部代劳**:真正去查天气 / 调 `API` 的活,在模型外面、你的代码里跑; 4. **结果回灌**:把执行结果喂回模型,它结合结果组织出人类看得懂的回答。 这就是最朴素的**工具调用闭环**:`模型决策 → 外部执行 → 模型总结`。网上多数科普到这一步就停了——它解决的是「**一步**就能搞定的活」:问天气,调一次 `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 | ### 2.1 注册工具:给模型看一张「说明书」 **痛点**:模型并不知道你手里有哪些工具、每个工具什么时候该用。你不交代清楚,它要么乱调,要么干脆不调。 **方案**:发起对话时,把可用工具连同说明书一起发给模型。每份说明书长这样—— - **名字**(`name`):模型用它来「点名」; - **描述**(`description`):这是最关键的一行——模型**靠读这段文字**来判断「这工具干嘛用的、什么时候该用它」; - **参数表**(`schema`):每个参数叫什么、什么类型、什么含义。 描述写得好不好,直接决定模型会不会调对工具。比如「根据城市名称查询当前真实天气」就比「查询天气」强——前者点明了输入是城市名、输出是实况,模型才不会拿经纬度去喂它。 注册时把工具列表绑到模型上(`bindTools`),之后每次对话模型都带着这份说明书: ```ts // 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` 列表递给驱动器: ```ts // 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 会用到。 ### 2.2 思考与决策(`Thought`) **痛点**:拿到用户问题后,模型可能需要调工具,也可能根本不需要(比如纯闲聊)。它得自己判断「下一步要不要动手、动哪个」。 **方案**:让模型读一遍**完整的对话历史**(包括之前几轮的工具结果),再决定本轮怎么回。这一步就是 `ReAct` 里的「思考」节点。 `LangGraph` 里它叫 `agent` / `chatbot` 节点:首轮注入一句系统提示(引导模型缺信息时主动问用户、而不是瞎猜),然后带着工具绑定的模型跑一次: ```ts // 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` 推到终端——也就是说,**观察还没回来之前,思考过程就已经对用户可见**了;冻结进历史的,以驱动器返回的完整结果为准: ```ts // 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` 让模型先吐「**我想调某个工具**」这个**意图**(结构化指令,而非最终文本),把「知道什么」和「能查到什么」分开:模型只负责想「该查什么」,外部负责真去查。这一步分开,是后面循环能跑起来的前提。 ### 2.3 行动与执行(`Action`) **痛点**:模型只会「说」不会「做」。它吐出的是一张「我想调 `get_weather({ location: '北京' })`」的便签,便签本身查不了天气。 **方案**:这张便签由**模型外面**的代码接住,真正去执行。执行这一段跟大模型一点关系都没有——就是普通的函数调用 / `API` 请求。 `LangGraph` 用一个现成的 `ToolNode` 干这件事:它解析上一条消息里的 `tool_calls`,按名字找到对应工具执行,把结果包成一条 `ToolMessage`: ```ts // packages/graph/src/weatherGraph.ts — 图装配(节选) .addNode('tools', new ToolNode(tools)) .addEdge('tools', 'agent') // 执行完固定回到 agent ``` `CLI` 侧没有 `ToolNode`,等价的逻辑写在 `handleToolCall` 里,而且多了一道**权限闸门**——执行前先看这工具危不危险: ```ts // 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` 落地时绕不开的安全考量。 ### 2.4 观察与回灌(`Observation`) **痛点**:执行完的结果,模型看不到就等于白干——它没法基于「刚才查到 22 度」去组织回答。 **方案**:把执行结果以一个**新角色**追加回对话历史。在 `LangGraph` 里是 `ToolMessage`;在 `OpenAI` 协议里是 `{ role: 'tool', ... }` 消息。模型下一轮读历史时,这条结果就跟用户的提问、它自己的指令一样,是上下文的一部分。 状态通道也顺理成章:`LangGraph` 不为「思考」「观察」单开字段,而是把 `HumanMessage`、`AIMessage`(含 `tool_calls`)、`ToolMessage` 全往 `messages` 一条通道里追加;`CLI` 侧同理,`llmMessages` 这一条数组就是 LLM 看到的真相: ```ts // 状态:只用 messages 一条通道 const WeatherState = Annotation.Root({ messages: Annotation({ reducer: (x, y) => x.concat(y), default: () => [], }), }) ``` **对比:为什么「思考」和「观察」要混在同一条消息流里,而不是分开存?** 因为模型一次 `invoke` 时,看到的就是这整段历史。分开存还得每次重新拼装、还容易拼错顺序;混在一条流里,最新的结果天然排在末尾,模型「读完上文再决定下一步」这件事就免费实现了。`ReAct` 循环的本质,就是这条不断变长的消息流。 ### 2.5 刹车与循环(`shouldContinue`) **痛点**:观察回来之后,是该再想一步,还是该结束?没有刹车,循环要么无限转,要么转一次就停。 **方案**:看模型**这轮吐没吐 `tool_calls`**——吐了,说明它还想动手,去执行;没吐,说明它已经能给最终答案了,结束。 `LangGraph` 里这道刹车是一条**条件边**: ```ts // 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`: ```ts // 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 轮。固定上限要么卡死在半路(任务没完成就硬结束),要么白等。「模型这轮还要不要动手」本身,就是最准的结束信号——它不发起调用了,自然就是觉得能答了。所以刹车挂在「意图」上,而不是挂在计时器上。 ### 2.6 人在回路:缺信息时问、危险操作时确认 **痛点**:循环跑起来后,两类情况模型自己兜不住—— 1. **缺信息**:用户说「查天气」却没说哪个城市。模型要是硬猜一个城市,就是幻觉的温床。 2. **危险操作**:比如要发一封邮件、删一条记录。让模型直接干,出事没人拦得住。 **方案**:在这两处把控制权**暂时交还给用户**——把循环挂起,等用户补一句、点个确认,再继续跑。这就是「人在回路」(`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` 协议,没有重复造轮子。 --- ## 三、流程实现 前面讲「为什么」和「每个环节怎么做」,这里把整条流水线长什么样画出来。 ### 3.1 `ReAct` 环(`agent ↔ tools`) `LangGraph` 的经典结构就是一个两节点环:`agent` 负责 `Thought`,`tools` 负责 `Action` 并回灌 `Observation`,靠条件边在两者间循环,直到 `agent` 决定走向 `END`。 ```mermaid flowchart TD S([__start__]) --> AGENT AGENT["agent: 读历史消息
决定是否调工具、调哪个"] --> CK{"末条 AIMessage
有 tool_calls ?"} CK -->|有| TOOLS["tools: 执行 tool call
结果写 ToolMessage 回灌"] TOOLS --> AGENT CK -->|无| E(["__end__: 最终答复"]) ``` | 图节点 | `ReAct` 角色 | 含义 | | ------ | ----------- | ---- | | `__start__` | 输入 | 用户问题进入图 | | `agent` | `Thought` | 读历史消息,决定是否调用工具、调哪个 | | `tools` | `Action` + `Observation` | 执行 `tool call`,结果写成 `ToolMessage` 回灌 | | `__end__` | `Final Answer` | 模型不再发起调用,给出最终答复,结束 | ### 3.2 `CLI` 的 `while` 环 `CLI` 端不依赖 `LangGraph`,用 `Effect` 编排出**同一个**循环:`chat()` = `agent` 节点,`handleToolCall()` = `tools` 节点,`while` 顶上的判空 = `shouldContinue`。 ```mermaid 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` 就是刹车: ```ts // 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` 就去执行、否则结束;执行结果回灌后回到思考**。 ### 3.3 人在回路的中断与续跑 工具执行到一半需要用户介入时,循环**挂起**而不是结束;用户回应后从挂起点**续跑**,`ToolNode` 把工具返回值包成 `Observation` 再回 `agent`。 ```mermaid 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` 的多轮循环和人在回路都走一遍。 ```mermaid 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` 说的「让模型一边想一边干」,落到代码上,就是这么一个会自己转、会中途停下来问你、问完接着转的环。 --- ## 五、完整示例:`tushareGraph`(`MCP` 工具 + `interrupt` 人在回路) 前面几节都在讲「`ReAct` 的每个环节是什么、为什么」,这一节把一个真实在跑的图完整摊开——对应 `packages/graph/src/tushareGraph.ts`,做 A 股个股分析。它的 `agent ↔ tools` 环和最简形态完全一样(`agent` + `tools` + `shouldContinue`),区别全在「工具从哪来」和「缺信息时怎么停下来问用户」两处。读懂这个例子,就等于把前面讲的 `Thought` / `Action` / `Observation` / `shouldContinue` / 人在回路,在一套真实代码里全走了一遍。 1. **工具来源是远程 `MCP`**:Tushare 的股票数据工具由 `https://api.tushare.pro/mcp` 提供,需要异步建连、`listTools` 拉取,无法像 `getWeatherTool` 那样在模块顶层 `new` 出来。 2. **懒加载单例**:`getTushareToolset()` 维护模块级 `toolsetPromise`,首次调用才 `createTushareMcp().then(buildTushareToolset)`;建连失败则重置 `promise` 允许重试。 3. **`resolve_stock` 用 `interrupt()` 实现人在回路**:缺名称/代码时弹 `input` 中断,多匹配时弹 `select` 中断,复用 `ASK_TOOLS` 的中断协议形状。 ### 5.1 `MCP` 工具适配:`mcpToLangchain.ts` `packages/graph/src/tools/mcpToLangchain.ts` 把远程 `MCP` 工具适配成 `LangChain` `DynamicStructuredTool`,供 `ToolNode` 调度: ```typescript 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) } 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` 提供,内部统一打请求日志(发起时间/参数/超时/耗时)。 ### 5.2 共享 `MCP` 客户端:`tushareClient.ts` `packages/tools/src/mcp/tushareClient.ts` 是 `CLI` 与 graph 共用的 Tushare `MCP` 客户端,导出 `createTushareMcp()` 与 `TOKEN_HINT`: - **传输层回退**:先尝试 `StreamableHTTPClientTransport`,失败回退到 `SSEClientTransport`;两者都带 `Authorization: Bearer ` + `X-Tushare-Token` 头。 - **统一超时**:`MCP_OP_TIMEOUT_MS = 15_000`,`withTimeout` 包裹 `connect` / `listTools`,避免 tushare 服务偶发不响应时永久 `hang`;超时即 `reject`,上层 `toolsetPromise` 重置后可重试。 - **结果格式化**:`formatCallToolResult` 把 `MCP` `content` 数组(text / resource / image / audio)拼成纯字符串,`isError` 时前缀「错误:」。 - **日志**:`listTools` 与每次 `callTool` 都打印 `[tushare-mcp]` 发起/完成/失败日志(含 ts、elapsed、args 前 200 字符、isError、返回长度)。 ### 5.3 股票解析纯逻辑:`stockResolve.ts` `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: {...}}`(`params` schema 为 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[]`。 ### 5.4 system prompt:`tusharePrompt.ts` + `prompts/tushare.md` `packages/tools/src/mcp/tusharePrompt.ts` 读取同目录 `prompts/tushare.md` 模板(A 股个股分析框架:量价 + 月线分析、`MCP` 工具使用规则),用 `renderPrompt` 把 `${to_day}` 占位替换为当日 `YYYYMMDD`,进程级渲染一次导出为 `TUSHARE_SYSTEM_PROMPT`(单一来源,`CLI` 与 graph 复用)。 ### 5.5 `resolve_stock` 工具:`interrupt` 人在回路 `tushareGraph.ts` 内 `createResolveStockTool(mcp)` 构造一个特殊的 `LangChain` tool,内部用 `interrupt()` 暂停图——这就是 §2.6 讲的「缺信息时停下来问用户」的完整落地: ```typescript 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`。 ### 5.6 工具集装配与懒加载 ```typescript async function buildTushareToolset(mcp: TushareMcp): Promise { 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 | null = null async function getTushareToolset(): Promise { if (!toolsetPromise) { toolsetPromise = createTushareMcp().then(buildTushareToolset) toolsetPromise.catch(() => { toolsetPromise = null }) // 失败重置,允许重试 } return toolsetPromise } ``` 懒加载是为了避免模块加载期强依赖 `TUSHARE_TOKEN`(`MCP` 需异步建连,与 `weatherGraph` 顶层构造不同)。 ### 5.7 `agent` / `tools` 节点 ```typescript 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})` 恢复。 ### 5.8 `fixMisplacedToolCalls`:stream 模式 workaround `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` 非数组)直接跳过。 ### 5.9 图装配 ```typescript 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`。 ### 5.10 测试覆盖 `tushareGraph.test.ts` 两个用例: 1. **可编译**:`tushareGraph.compile({ checkpointer: new MemorySaver() })` 不抛(验证懒加载 `MCP`,不依赖 `TUSHARE_TOKEN`)。 2. **`resolve_stock` 多匹配 `interrupt` → `resume` round-trip**:mock `MCP` 的 `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` 为空(图结束)。