第 04 章

快速上手

从零构建一个能算术的智能体

工具 + 状态 + 图 = 完整的 AI 智能体

光说不练

前三章学了概念和模式,但还没有真正跑过一行代码。是时候动手了。

前几章

  • 知道了节点、边、状态这些概念
  • 看了六种工作流模式的示意图
  • 但没有写过完整的可运行代码

这一章

  • 从 npm init 开始,搭一个完整项目
  • 定义工具、状态、节点、边
  • 跑起来一个能做加减乘除的智能体
目标:用最少的代码,跑通一个完整的 LangGraph 智能体。每个步骤都有可运行的代码。

开始之前

你需要有

  • Node.js 18+ 和 npm
  • 一个 DeepSeek API Key
  • 读过前三章(至少了解节点、边、状态的概念)

学完后你应该能

  • 用 tool() 定义自定义工具
  • 用 StateSchema 定义图状态
  • 组装节点和边,编译成可运行的图
  • 理解 MessagesValue 和 ReducedValue 的区别

项目初始化

三步创建项目并安装依赖

思考题:你第一次用 LangGraph.js,完全不知道怎么建项目——需要安装哪些依赖?tsconfig 怎么配?怎么运行 .ts 文件?
# 创建项目目录
$ mkdir calculator-agent && cd calculator-agent
$ npm init -y

# 安装核心依赖
$ npm install @langchain/langgraph @langchain/core @langchain/deepseek zod

# 安装 TypeScript(如果还没装)
$ npm install -D typescript tsx

@langchain/langgraph

图编排框架:StateGraph、StateSchema、MessagesValue 等

@langchain/deepseek

DeepSeek 模型集成:ChatDeepSeek

zod

参数校验:为工具定义输入 Schema

API Key:运行前设置环境变量。export DEEPSEEK_API_KEY=sk-...,或者创建 .env 文件。别把 Key 硬编码在代码里。

📄 完整代码:04_env_setup.ts

定义工具

用 tool() + zod 创建三个数学工具

思考题:工具可能会出错——除法工具遇到除数为零怎么办?需要可选参数的工具怎么定义?工具的参数描述怎么写,LLM 才能正确调用?
import { ChatDeepSeek } from "@langchain/deepseek";
import { tool } from "@langchain/core/tools";
import * as z from "zod";

const model = new ChatDeepSeek({
  model: "deepseek-chat",
  temperature: 0,
});

// 加法工具
const add = tool(({ a, b }) => a + b, {
  name: "add",
  description: "Add two numbers",
  schema: z.object({
    a: z.number().describe("First number"),
    b: z.number().describe("Second number"),
  }),
});

// 乘法工具
const multiply = tool(({ a, b }) => a * b, {
  name: "multiply",
  description: "Multiply two numbers",
  schema: z.object({
    a: z.number().describe("First number"),
    b: z.number().describe("Second number"),
  }),
});

// 除法工具
const divide = tool(({ a, b }) => a / b, {
  name: "divide",
  description: "Divide two numbers",
  schema: z.object({
    a: z.number().describe("First number"),
    b: z.number().describe("Second number"),
  }),
});

// 按名称索引工具,绑定到模型
const toolsByName = { [add.name]: add, [multiply.name]: multiply, [divide.name]: divide };
const tools = Object.values(toolsByName);
const modelWithTools = model.bindTools(tools);
如果你看到报错:
  • AuthenticationError 或 Could not find API key → 确认已设置 DEEPSEEK_API_KEY 环境变量:$ export DEEPSEEK_API_KEY=sk-...
  • NotFoundError: model "xxx" not found → 检查模型名称拼写是否正确,或尝试换一个可用模型

📄 完整代码:04_tool_definition.ts

tool() 做了什么:把一个普通函数包装成 LLM 可调用的工具。zod schema 告诉模型这个工具接受什么参数——模型自己决定什么时候调用。

定义状态

MessagesValue 追踪对话,ReducedValue 追踪调用次数

图:状态在节点间的流转
import {
  StateGraph, StateSchema, MessagesValue, ReducedValue,
  GraphNode, ConditionalEdgeRouter, START, END,
} from "@langchain/langgraph";

const MessagesState = new StateSchema({
  // 消息历史 — 内置追加 reducer,新消息自动拼到末尾
  messages: MessagesValue,
  // LLM 调用计数 — 自定义 reducer,每次 +1
  llmCalls: new ReducedValue(
    z.number().default(0),
    { reducer: (x, y) => x + y }
  ),
});

MessagesValue

内置的消息追加器。节点返回 { messages: [newMsg] },新消息自动拼到已有消息列表末尾。

ReducedValue

自定义 reducer 的字段。这里每次返回 { llmCalls: 1 },reducer 把它加到已有值上。

模型节点

调用 LLM,把回复追加到消息历史

import { SystemMessage } from "@langchain/core/messages";

const llmCall: GraphNode<typeof MessagesState> = async (state) => {
  const response = await modelWithTools.invoke([
    new SystemMessage(
      "You are a helpful assistant tasked with performing arithmetic on a set of inputs."
    ),
    ...state.messages,
  ]);
  return {
    messages: [response],  // MessagesValue 自动追加
    llmCalls: 1,            // ReducedValue 累加 +1
  };
};
节点就是函数:接收当前状态,返回状态更新。你不需要手动操作整个状态——只需要返回你想修改的字段,LangGraph 的 reducer 会处理合并。

工具节点

执行模型请求的工具调用,把结果追加到消息历史

import { AIMessage, ToolMessage } from "@langchain/core/messages";

const toolNode: GraphNode<typeof MessagesState> = async (state) => {
  const lastMessage = state.messages.at(-1);

  // 安全检查:最后一条必须是 AI 消息
  if (lastMessage == null || !AIMessage.isInstance(lastMessage)) {
    return { messages: [] };
  }

  // 逐个执行工具调用
  const result: ToolMessage[] = [];
  for (const toolCall of lastMessage.tool_calls ?? []) {
    const tool = toolsByName[toolCall.name];
    const observation = await tool.invoke(toolCall);
    result.push(observation);
  }

  return { messages: result };
};
流程:模型说"我要调用 add(3, 4)"→ toolNode 执行 add → 返回结果 7 → 追加为 ToolMessage → 模型看到结果后决定下一步。

结束判断

条件边:模型还要调工具吗?

const shouldContinue: ConditionalEdgeRouter<
  typeof MessagesState, "toolNode"
> = (state) => {
  const lastMessage = state.messages.at(-1);

  // 不是 AI 消息 → 结束
  if (!lastMessage || !AIMessage.isInstance(lastMessage)) {
    return END;
  }

  // AI 请求了工具调用 → 去执行工具
  if (lastMessage.tool_calls?.length) {
    return "toolNode";
  }

  // AI 没有请求工具 → 回复用户,结束
  return END;
};
条件边的逻辑:看完最后一条消息。如果 AI 说了"我要调工具",就去 toolNode;如果 AI 直接给了答案,就结束。

组装智能体

把所有零件拼成一个完整的图

思考题:你想做一个"计算器 Agent"——用户说"算一下 15+27",Agent 理解意图后调用加法工具,拿到结果再告诉用户。如果用户接着问"再乘 2",Agent 要记得上一轮的对话上下文。怎么把工具、记忆、循环组合起来?
const agent = new StateGraph(MessagesState)
  .addNode("llmCall", llmCall)
  .addNode("toolNode", toolNode)
  .addEdge(START, "llmCall")
  .addConditionalEdges("llmCall", shouldContinue, ["toolNode", END])
  .addEdge("toolNode", "llmCall")
  .compile();
图:完整的智能体执行流程
关键连线:toolNode → llmCall 形成循环。工具执行完后,模型需要看到结果才能决定下一步——继续调工具,还是给用户最终答案。

📄 完整代码:04_calculator_agent.ts

运行测试

用一句话启动智能体

import { HumanMessage } from "@langchain/core/messages";

const result = await agent.invoke({
  messages: [new HumanMessage("Add 3 and 4.")],
});

// 打印完整的消息历史
for (const message of result.messages) {
  console.log(`[${message.type}]: ${message.text}`);
}
# 预期输出:
[human]: Add 3 and 4.
[ai]: [调用 add 工具, a=3, b=4]
[tool]: 7
[ai]: The answer is 7.
# llmCalls: 2(模型被调用了两次)
发生了什么:用户说"Add 3 and 4"→ 模型决定调用 add(3,4) → 工具返回 7 → 模型看到结果,回答"The answer is 7"。模型被调用了两次(第一次决定调工具,第二次生成最终答案)。

排错指南

API Key 报错

症状:AuthenticationError: Invalid API Key

修复:检查环境变量 DEEPSEEK_API_KEY 是否设置正确。

工具名找不到

症状:Tool not found: add

修复:确认 toolsByName 的 key 和工具的 name 属性一致。注意大小写。

消息类型错误

症状:lastMessage.tool_calls is not a function

修复:先检查 AIMessage.isInstance(lastMessage),不是所有消息都有 tool_calls。

无限循环

症状:智能体一直调工具,不停止。

修复:检查 shouldContinue 逻辑——确保模型不调工具时返回 END,而不是继续循环。

动手练习

给计算器智能体加上减法和乘方

任务:在现有代码基础上,做三件事。
  • 添加 subtract 工具(a - b)和 power 工具(a ^ b)
  • 把它们注册到 toolsByName 并绑定到模型
  • 测试 "What is 2 to the power of 10?",确认智能体能正确调用 power 工具

参考答案

// 减法工具
const subtract = tool(({ a, b }) => a - b, {
  name: "subtract",
  description: "Subtract b from a",
  schema: z.object({
    a: z.number().describe("First number"),
    b: z.number().describe("Second number"),
  }),
});

// 乘方工具
const power = tool(({ a, b }) => Math.pow(a, b), {
  name: "power",
  description: "Raise a to the power of b",
  schema: z.object({
    a: z.number().describe("Base"),
    b: z.number().describe("Exponent"),
  }),
});

// 更新工具注册
const toolsByName = {
  [add.name]: add,
  [multiply.name]: multiply,
  [divide.name]: divide,
  [subtract.name]: subtract,
  [power.name]: power,
};
const modelWithTools = model.bindTools(Object.values(toolsByName));
关键点:只需要添加工具定义并重新绑定。图的拓扑结构完全不变——llmCall 和 toolNode 的代码一行都不用改。

本章回顾

到这里,你已经能

  • 用 tool() + zod 定义自定义工具
  • 用 StateSchema + MessagesValue 定义图状态
  • 编写模型节点和工具节点
  • 用条件边控制循环和终止
  • 组装、编译并运行完整的智能体

它和前后章节的关系

  • 概述 — 本章用到的所有概念
  • 模式(上) — 本章实现了"智能体"模式
  • 快速上手 — 第一个可运行的智能体
  • 持久化 — 让智能体拥有记忆
核心结论:一个 LangGraph 智能体 = 工具定义 + 状态定义 + 两个节点(模型/工具)+ 一个条件边。零件很少,但组合起来很强大。

延伸阅读

继续本课程