ReAct Agent

要点

  • 前五篇分别学了 StateGraph 的节点与边、Reducer 的状态合并、条件边的循环控制、Checkpointer 的状态持久化
  • 在写 Agent 之前,先搞清楚一个根本问题:Agent 和普通程序的区别是什么
  • Agent 的能力边界由工具决定
  • 1 状态、模型、节点
  • 1 Agent 自己选择工具

内容

1. 前五篇的零件,这一篇组装

前五篇分别学了 StateGraph 的节点与边、Reducer 的状态合并、条件边的循环控制、Checkpointer 的状态持久化。

每一篇解决一个具体问题,但始终没有放在一起。

这一篇就是「回报点」——把所有零件组装成一个完整的 ReAct Agent

构建完成后你会发现,核心图定义只有三行。之前五篇铺垫的知识点,在这三行里全部用上了。

2. 什么是 Agent:模型自己决定下一步做什么

在写 Agent 之前,先搞清楚一个根本问题:Agent 和普通程序的区别是什么?

普通程序的执行路径是你写死的。用户输入 → 调 API → 拼结果 → 返回。每一步做什么、做几次、什么时候停下来,全由代码决定。

Agent 不一样。你把工具交给模型,模型自己决定:

  • 调不调工具——用户说「你好」,不需要调工具,直接回复
  • 调哪个工具——用户说「北京天气」,模型选择 get_weather 而不是 calculate
  • 传什么参数——模型从用户的自然语言中提取 city: "北京"
  • 调几次——用户说「北京和上海的天气」,模型一次调两个
  • 什么时候停——拿到所有结果后,模型决定不再调工具,生成最终回复

这就是「自主性」。你不写 if...else 来分发请求,模型根据上下文自己做判断。

ReAct(Reasoning + Acting)是实现这种自主性最经典的模式。它的循环很简单:

// react-loop.txt
用户: 北京明天天气怎么样?
 
  [思考] 用户想知道天气,我需要调用天气工具
 
  [行动] 调用 get_weather({ city: "北京" })
 
  [观察] 工具返回:晴,12-25°C
 
  [思考] 已经拿到天气信息,可以直接回复了
 
  [行动] 生成最终回复
 
AI: 北京明天天气晴朗,气温 12-25°C,适合出门!

对应到 LangGraph,就是第四篇讲过的条件边循环:

  • callModel 节点——模型思考 + 决定行动(调工具还是直接回复)
  • callTools 节点——执行工具,把结果放回消息列表(观察)
  • 条件边——有工具调用就继续循环,没有就结束

3. 准备工具:给 Agent 装上能力

Agent 的能力边界由工具决定。我们准备三个工具:查天气、做计算、查时间。

// tools.ts
import { tool } from '@langchain/core/tools'
 
import * as z from 'zod'
 
// 查天气
 
const getWeather = tool(
 
  async ({ city }) => {
 
    const data: Record<string, string> = {
 
      北京: '晴,12-25°C',
 
      上海: '小雨,17-22°C',
 
      深圳: '多云,26-30°C',
 
    }
 
    return data[city] ?? `暂无 ${city} 的天气数据`
 
  },
 
  {
 
    name: 'get_weather',
 
    description: '查询指定城市的天气',
 
    schema: z.object({
 
      city: z.string().describe('城市名称'),
 
    }),
 
  },
 
)
 
// 做计算
 
const calculate = tool(
 
  async ({ expression }) => {
 
    try {
 
      // 生产环境请用安全的表达式解析器,这里仅做演示
 
      const result = new Function(`return ${expression}`)()
 
      return `${expression} = ${result}`
 
    } catch {
 
      return `无法计算: ${expression}`
 
    }
 
  },
 
  {
 
    name: 'calculate',
 
    description: '计算数学表达式',
 
    schema: z.object({
 
      expression: z.string().describe('数学表达式,例如 "2 + 3 * 4"'),
 
    }),
 
  },
 
)
 
// 查时间
 
const getCurrentTime = tool(
 
  async () => {
 
    return new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })
 
  },
 
  {
 
    name: 'get_current_time',
 
    description: '获取当前北京时间',
 
    schema: z.object({}),
 
  },
 
)
 
const tools = [getWeather, calculate, getCurrentTime]

每个工具的结构都一样:一个执行函数 + 一个描述对象。namedescription 是给模型看的——模型根据这些信息决定什么时候调用哪个工具。schema 用 Zod 定义参数结构,LangChain 会自动把它转成模型需要的 JSON Schema 格式。

4. 构建 ReAct Agent

4.1 状态、模型、节点

// agent-core.ts
import {
 
  StateGraph,
 
  StateSchema,
 
  MessagesValue,
 
  START,
 
  END,
 
} from '@langchain/langgraph'
 
import type { GraphNode, ConditionalEdgeRouter } from '@langchain/langgraph'
 
import { InMemorySaver } from '@langchain/langgraph'
 
import { ChatOpenAI } from '@langchain/openai'
 
// 状态:只需要一个消息列表
 
// 用户输入、模型回复、工具调用请求、工具执行结果——全部是消息
 
const State = new StateSchema({
 
  messages: MessagesValue,
 
})
 
// 模型:绑定工具后,模型在推理时会自动考虑是否调用它们
 
const model = new ChatOpenAI({ model: 'gpt-4.1-mini' }).bindTools(tools)
 
// 节点 1:调用模型
 
// 把所有消息(含历史对话和工具结果)发给模型,拿回回复
 
const callModel: GraphNode<typeof State> = async (state) => {
 
  const response = await model.invoke(state.messages)
 
  return { messages: [response] }
 
}
 
// 节点 2:执行工具
 
// 从模型回复中取出工具调用请求,执行后把结果放回消息列表
 
const toolsByName = Object.fromEntries(tools.map(t => [t.name, t]))
 
const callTools: GraphNode<typeof State> = async (state) => {
 
  const lastMsg = state.messages.at(-1)!
 
  const toolCalls = lastMsg.tool_calls ?? []
 
  // 多个工具调用并行执行
 
  const results = await Promise.all(
 
    toolCalls.map(async (tc) => {
 
      try {
 
        const result = await toolsByName[tc.name].invoke(tc.args)
 
        return { role: 'tool' as const, content: result, tool_call_id: tc.id }
 
      } catch (err) {
 
        // 工具失败时把错误信息返回给模型,让它自己决定怎么应对
 
        const errorMsg = err instanceof Error ? err.message : String(err)
 
        return {
 
          role: 'tool' as const,
 
          content: `工具执行失败: ${errorMsg}`,
 
          tool_call_id: tc.id,
 
        }
 
      }
 
    }),
 
  )
 
  return { messages: results }
 
}

4.2 路由和组装

// build-graph.ts
// 路由:模型回复里有工具调用就继续循环,没有就结束
 
const shouldContinue: ConditionalEdgeRouter<typeof State, 'callTools'> = (state) => {
 
  const lastMsg = state.messages.at(-1)!
 
  if (lastMsg.tool_calls && lastMsg.tool_calls.length > 0) {
 
    return 'callTools'
 
  }
 
  return END
 
}
 
// 组装图
 
const graph = new StateGraph(State)
 
  .addNode('callModel', callModel)
 
  .addNode('callTools', callTools)
 
  .addEdge(START, 'callModel')                                     // 入口
 
  .addConditionalEdges('callModel', shouldContinue, ['callTools'])  // 条件分支
 
  .addEdge('callTools', 'callModel')                               // 循环回路
 
  .compile({
 
    checkpointer: new InMemorySaver(),  // 支持多轮对话
 
  })

三行图定义,就是整个 ReAct Agent 的控制流。对照第四篇的条件边循环:

  • START → callModel:收到消息后,先让模型思考
  • callModel → shouldContinue → callTools:模型决定调工具,执行它
  • callTools → callModel:工具结果回到模型,继续思考

模型决定不再调工具时,shouldContinue 返回 END,循环结束。

5. 运行:观察 Agent 的自主决策

5.1 Agent 自己选择工具

// single-turn.ts
const config = { configurable: { thread_id: 'test-001' } }
 
const result = await graph.invoke(
 
  { messages: [{ role: 'user', content: '北京和上海今天天气怎么样?顺便算下 365 * 24' }] },
 
  config,
 
)
 
for (const msg of result.messages) {
 
  const type = msg.getType()
 
  if (type === 'human') {
 
    console.log(`[用户] ${msg.content}`)
 
  } else if (type === 'ai' && msg.tool_calls?.length) {
 
    console.log(`[模型决策] 调用工具: ${msg.tool_calls.map(tc => `${tc.name}(${JSON.stringify(tc.args)})`).join(', ')}`)
 
  } else if (type === 'tool') {
 
    console.log(`[工具结果] ${msg.content}`)
 
  } else {
 
    console.log(`[模型回复] ${msg.content}`)
 
  }
 
}

输出:

// output.txt
[用户] 北京和上海今天天气怎么样?顺便算下 365 * 24
 
[模型决策] 调用工具: get_weather({"city":"北京"}), get_weather({"city":"上海"}), calculate({"expression":"365 * 24"})
 
[工具结果] 晴,12-25°C
 
[工具结果] 小雨,17-22°C
 
[工具结果] 365 * 24 = 8760
 
[模型回复] 北京今天天气晴朗,气温 12-25°C;上海有小雨,17-22°C,记得带伞。365 × 24 = 8760,一年有 8760 个小时!

注意看 [模型决策] 那一行:你没有写任何代码告诉模型「先查天气再算数」。模型自己分析了用户的请求,判断需要调三个工具,一次性发出了三个调用请求。这就是第二节说的「自主性」——控制流不是你写死的,是模型推理出来的。

如果用户改问「你好」,模型会判断不需要任何工具,直接回复。你不需要为此写一行额外的代码。

5.2 多轮对话:Checkpointer 的效果

// multi-turn.ts
const config = { configurable: { thread_id: 'test-002' } }
 
// 第一轮
 
await graph.invoke(
 
  { messages: [{ role: 'user', content: '现在几点了?' }] },
 
  config,
 
)
 
// 第二轮:同一个 thread_id,上下文自动累积
 
const r2 = await graph.invoke(
 
  { messages: [{ role: 'user', content: '帮我算一下 365 * 24' }] },
 
  config,
 
)
 
console.log(r2.messages.at(-1)?.content)
 
// → 365 × 24 = 8760,一年有 8760 个小时。
 
// 第三轮:Agent 记得之前的对话
 
const r3 = await graph.invoke(
 
  { messages: [{ role: 'user', content: '前面我问了你什么?' }] },
 
  config,
 
)
 
console.log(r3.messages.at(-1)?.content)
 
// → 你先问了现在几点,然后问一年有多少小时。

多轮对话的能力来自第五篇的 Checkpointer。同一个 thread_id 下,每次 invoke 时 Checkpointer 先加载上一次的状态,新消息通过 MessagesValue 追加到历史后面。你只需要传本轮新增的消息,历史对话由 Checkpointer 自动管理。

6. 添加系统提示:给 Agent 一个人格

裸的 ReAct Agent 已经能跑了。生产环境最常见的定制是加一个系统提示,控制 Agent 的行为风格。

最简单的方式——在第一轮调用时把系统消息放在消息列表最前面:

// system-prompt.ts
const config = { configurable: { thread_id: 'companion-001' } }
 
const result = await graph.invoke(
 
  {
 
    messages: [
 
      {
 
        role: 'system',
 
        content: `你是一个贴心的 AI 伴侣。
 
规则:
 
1. 用温暖、口语化的方式回复
 
2. 如果用户问天气,主动给出穿衣建议
 
3. 如果无法确定答案,坦诚说不知道,不要编造`,
 
      },
 
      { role: 'user', content: '深圳天气怎么样?' },
 
    ],
 
  },
 
  config,
 
)
 
console.log(result.messages.at(-1)?.content)
 
// → 深圳今天多云,26-30°C,挺热的!穿短袖就好,出门记得涂防晒~

如果你把系统消息也放进 messages,同一个 thread_id 下后续轮次通常会继续带着它,所以很多场景里不用每轮都重复传。

如果系统提示需要动态变化(比如根据用户身份调整),更好的做法是在 callModel 节点里注入:

// dynamic-system-prompt.ts
const callModel: GraphNode<typeof State> = async (state, config) => {
 
  const userId = config?.configurable?.user_id ?? 'anonymous'
 
  // 每次调模型时在消息最前面插入系统提示
 
  const systemMessage = {
 
    role: 'system' as const,
 
    content: `你是用户 ${userId} 的 AI 伴侣。用温暖的方式回复。`,
 
  }
 
  const response = await model.invoke([systemMessage, ...state.messages])
 
  return { messages: [response] }
 
}

7. 生产环境:createReactAgent

前面手动构建了整个 ReAct Agent,是为了理解内部结构。但在实际开发中,LangGraph 提供了一个开箱即用的工具函数 createReactAgent一行代码就能创建标准的 ReAct Agent

// create-react-agent.ts
import { createReactAgent } from '@langchain/langgraph/prebuilt'
 
import { ChatOpenAI } from '@langchain/openai'
 
import { InMemorySaver } from '@langchain/langgraph'
 
const model = new ChatOpenAI({ model: 'gpt-4.1-mini' })
 
// 一行搞定:模型 + 工具 + Checkpointer
 
const agent = createReactAgent({
 
  model,
 
  tools: [getWeather, calculate, getCurrentTime],
 
  checkpointer: new InMemorySaver(),
 
})
 
// 用法和手动构建的完全一样
 
const result = await agent.invoke(
 
  { messages: [{ role: 'user', content: '北京天气怎么样?' }] },
 
  { configurable: { thread_id: 'quick-001' } },
 
)

createReactAgent 内部做的事和我们手动构建的完全一样:定义 callModel 节点、callTools 节点、条件边循环、编译图。它是一层封装,不是黑魔法。

什么时候用 createReactAgent,什么时候手动构建?

  • 标准的「模型 + 工具」场景 → 用 createReactAgent,省时间
  • 需要在循环中插入额外步骤(安全检查、日志记录、人工审批)→ 手动构建,因为你需要控制节点和边的结构

理解了本文的手动构建过程,你就知道 createReactAgent 省略了什么,也知道在需要定制时从哪里下手。

8. 总结

这一篇把前五篇的知识点串在了一起:

知识点来自在 ReAct Agent 中的作用
StateSchema + MessagesValue第二篇定义状态,消息自动追加合并
条件边 + 循环第四篇callModel → callTools → callModel 的 ReAct 循环
InMemorySaver + thread_id第五篇多轮对话的状态持久化
GraphNode + ConditionalEdgeRouter第二、四篇节点和路由函数的类型约束

Agent 的核心不是代码量,而是一个设计决策:把「下一步做什么」的决定权交给模型。 你定义好工具、接好循环,模型在循环里自主推理、自主行动、自主判断何时停下。这就是从「程序」到「Agent」的跨越。

下一篇讲流式输出——让 Agent 的回复像打字一样逐字出现,而不是等所有工具执行完才一次性返回。