Skip to content
kscarrot edited this page Jul 31, 2026 · 6 revisions

ReAct

这篇文档讲的是:怎么让大模型不只是「嘴上说说」,而是能真正动手干活——查实时天气、查股价、调一个外部 API。

要讲清这件事,得分两层:先讲「工具调用(Tool Call)」这套最朴素的闭环——模型怎么发一张「指令便签」给外部程序去执行、再把结果收回来;再讲「ReAct」怎么把「思考」和「行动」交替循环,让模型能搞定那种需要好几步、中间还得看情况调整的复杂任务。

可以把它想成做一道数学大题:先想「这题该用什么方法」(思考),再「列式算一步」(行动),算完「看看结果对不对、还差什么」(观察),再决定下一步——如此循环,直到写出最终答案。大模型自己查不了今天的天气,但它会写一张小纸条递给「机械臂」,机械臂干完把结果递回来,它看了纸条再决定下一步该干什么。ReAct 就是把这套「想一步 → 干一步 → 看一眼 → 再想」不停循环,直到任务完成的套路,交给大模型自动跑完。

学界把这套思路叫 ReAct(Reasoning and Acting,推理与行动),由 Yao 等 2022 提出;本仓库在 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),之后每次对话模型都带着这份说明书:

// 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 会用到。

2.2 思考与决策(Thought)

痛点:拿到用户问题后,模型可能需要调工具,也可能根本不需要(比如纯闲聊)。它得自己判断「下一步要不要动手、动哪个」。

方案:让模型读一遍完整的对话历史(包括之前几轮的工具结果),再决定本轮怎么回。这一步就是 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 让模型先吐「我想调某个工具」这个意图(结构化指令,而非最终文本),把「知道什么」和「能查到什么」分开:模型只负责想「该查什么」,外部负责真去查。这一步分开,是后面循环能跑起来的前提。

2.3 行动与执行(Action)

痛点:模型只会「说」不会「做」。它吐出的是一张「我想调 get_weather({ location: '北京' })」的便签,便签本身查不了天气。

方案:这张便签由模型外面的代码接住,真正去执行。执行这一段跟大模型一点关系都没有——就是普通的函数调用 / API 请求。

LangGraph 用一个现成的 ToolNode 干这件事:它解析上一条消息里的 tool_calls,按名字找到对应工具执行,把结果包成一条 ToolMessage:

// packages/graph/src/weatherGraph.ts — 图装配(节选)
  .addNode('tools', new ToolNode(tools))
  .addEdge('tools', 'agent')   // 执行完固定回到 agent

CLI 侧没有 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 落地时绕不开的安全考量。

2.4 观察与回灌(Observation)

痛点:执行完的结果,模型看不到就等于白干——它没法基于「刚才查到 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 循环的本质,就是这条不断变长的消息流。

2.5 刹车与循环(shouldContinue)

痛点:观察回来之后,是该再想一步,还是该结束?没有刹车,循环要么无限转,要么转一次就停。

方案:看模型这轮吐没吐 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 轮。固定上限要么卡死在半路(任务没完成就硬结束),要么白等。「模型这轮还要不要动手」本身,就是最准的结束信号——它不发起调用了,自然就是觉得能答了。所以刹车挂在「意图」上,而不是挂在计时器上。

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。

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__: 最终答复"])
Loading
图节点 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。

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
Loading

整张图落到代码上,就是 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 就去执行、否则结束;执行结果回灌后回到思考。

3.3 人在回路的中断与续跑

工具执行到一半需要用户介入时,循环挂起而不是结束;用户回应后从挂起点续跑,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
Loading

四、端到端时序

从用户问一句「北京今天天气怎么样」,到屏幕上出现流式回答,大致按下面这条时间线走。可以把 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: 历史冻结
Loading

整条时间线,其实就是把 §一的链路逐拍展开:思考 → 行动 → 执行 → 观察 → 再思考,循环两轮,第二轮不再发起调用,于是刹车触发、给出最终答案。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 调度:

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 提供,内部统一打请求日志(发起时间/参数/超时/耗时)。

5.2 共享 MCP 客户端: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 把 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 讲的「缺信息时停下来问用户」的完整落地:

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 工具集装配与懒加载

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 顶层构造不同)。

5.7 agent / tools 节点

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 图装配

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 为空(图结束)。

Clone this wiki locally