mkdir lingshi && cd lingshipnpm init
这一步没什么特别的,得到一个 package.json,后面往里加依赖就行。

# 运行时依赖pnpm add langchain @langchain/anthropic @langchain/core @langchain/langgraph deepagents zod dotenv# 开发依赖(TypeScript 相关)pnpm add -D typescript tsx @types/node
简单解释一下每个包的职责:
| 包名 | 作用 |
|---|---|
langchain | LangChain 核心框架 |
@langchain/anthropic | Anthropic 兼容接口的 ChatModel |
@langchain/core | 核心工具定义(tool 函数从这里来) |
@langchain/langgraph | Agent 的图结构引擎 + MemorySaver |
deepagents | 封装了 createDeepAgent,简化 Agent 创建 |
zod | 运行时类型校验,用来定义工具的参数 schema |
dotenv | 加载 .env 文件中的环境变量 |
tsx | 直接运行 TypeScript,不需要先编译 |
创建 tsconfig.json:
{"compilerOptions": {"target": "ES2022","module": "ESNext","moduleResolution": "bundler","esModuleInterop": true,"strict": true,"skipLibCheck": true,"noEmit": true,"types": ["node"]},"include": ["src/**/*"]}
几个关键配置:
module: "ESNext" — 使用 ES 模块(import/export),对应 package.json 中的 "type": "module"moduleResolution: "bundler" — 适配现代工具的模块解析策略noEmit: true — 我们只用 tsx 直接运行,不需要 tsc 输出编译产物创建 .env 文件(敏感信息不要提交到 Git):
ANTHROPIC_API_KEY=sk-your-key-hereANTHROPIC_BASE_URL=https://your-proxy-server.com/anthropicMODEL_NAME=qwen3.7-plus
这里用了一个小技巧:通过 ANTHROPIC_BASE_URL 指向一个袋里服务器,底层实际跑的是 qwen3.7-plus 模型,但因为接口兼容 Anthropic 协议,所以可以直接用 ChatAnthropic 来调用。
最终的文件结构很简洁:
lingshi/├── src/│ ├── tools.ts# 工具定义(计算器、获取时间)│ ├── agents.ts # Agent 创建与配置│ └── index.ts# 入口文件,运行测试├── .env# 环境变量(API Key 等)├── package.json└── tsconfig.json
三个文件各司其职,下面逐一展开。
在写代码之前,先搞清楚一个核心问题:Agent 到底是什么?
用户输入 → LLM → 一次性返回结果,结束
LLM 就是一个"文本接龙机器"——你给它一段 prompt,它吐出一段回复,完事。它不能帮你查天气,不能帮你算数学,不能访问任何外部系统。
用户输入 → LLM → 需要工具吗?├─ Yes → 执行工具 → 结果喂回 LLM → 继续判断...└─ No→ 返回最终回复
Agent 在 LLM 的基础上加了一个循环:
while (LLM 觉得还需要工具) {执行工具 → 结果喂回 LLM}return LLM 的最终回答
举个例子:用户问"帮我算 128 × 47"
calculator({ a:128, b:47, operation:'multiply' })"128 multiply 47 = 6016""128 × 47 = 6016"Agent = LLM + 工具 + 循环,这就是全部。
工具的完整代码在 src/tools.ts 中。
Tool Calling 是 Agent 的核心机制,它的工作流程分 5 步:
第 2 步和第 3 步之间可能存在多次循环,这就是所谓的 Agent Loop。
import { tool } from '@langchain/core/tools';import { z } from 'zod';export const calculatorTool = tool(// 第一个参数:工具的执行函数async ({ a, b, operation }) => {let result: number;switch (operation) {case 'add':result = a + b; break;case 'subtract': result = a - b; break;case 'multiply': result = a * b; break;case 'divide':if (b === 0) return '错误:除数不能为零';result = a / b;break;default:return `错误:不支持的操作 "${operation}"`;}return `${a} ${operation} ${b} = ${result}`;},// 第二个参数:工具的元信息{name: 'calculator',description: '对两个数字执行四则运算(加、减、乘、除)',schema: z.object({a: z.number().describe('第一个数字'),b: z.number().describe('第二个数字'),operation: z.enum(['add', 'subtract', 'multiply', 'divide']).describe('要执行的操作:add(加)、subtract(减)、multiply(乘)、divide(除)'),}),});
每个工具有三要素:
| 要素 | 说明 |
|---|---|
name | 工具的唯一标识,LLM 通过这个名字来调用 |
description | 告诉 LLM 这个工具能干什么,LLM 据此决定是否使用 |
schema | Zod 定义的参数类型,框架会转成 JSON Schema 发给 LLM |
Zod 是一个 TypeScript-first 的运行时类型校验库。在 Deep Agents 中,Zod schema 承担了一个关键职责:告诉 LLM 该怎么传参数。
z.object({a: z.number().describe('第一个数字'),b: z.number().describe('第二个数字'),operation: z.enum(['add', 'subtract', 'multiply', 'divide']).describe('要执行的操作'),})
框架内部会把这段 Zod schema 转换成 JSON Schema,大致长这样:
{"type": "object","properties": {"a": { "type": "number", "description": "第一个数字" },"b": { "type": "number", "description": "第二个数字" },"operation": {"type": "string","enum": ["add", "subtract", "multiply", "divide"],"description": "要执行的操作"}},"required": ["a", "b", "operation"]}
LLM 看到这份 JSON Schema,就知道调用 calculator 时需要传 a、b(数字)和 operation(枚举字符串)。.describe() 里的描述是 LLM 理解参数含义的关键——不写描述,LLM 就只能猜。
不是所有工具都需要参数。获取当前时间就是一个典型:
export const getCurrentTimeTool = tool(async () => {const now = new Date();return `当前时间不好说:${now.toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}`;},{name: 'get_current_time',description: '获取当前的系统时间(北京时间)',schema: z.object({}),// 空 schema → LLM 知道不用传参});
z.object({}) 即空对象 schema,LLM 看到它就知道调用时不用传任何参数。
代码在 src/agents.ts 中。
import { ChatAnthropic } from '@langchain/anthropic';const model = new ChatAnthropic({model: process.env.MODEL_NAME || 'qwen3.7-plus',anthropicApiKey: process.env.ANTHROPIC_API_KEY,anthropicApiUrl: process.env.ANTHROPIC_BASE_URL,streaming: true,maxTokens: 10000,thinking: {type: 'enabled',budget_tokens: 5000,},});
ChatAnthropic 是 LangChain 的 ChatModel 实现之一。ChatModel 是 LangChain 对"对话模型"的统一抽象,提供两个核心方法:
.invoke(messages) — 同步调用,等完整回复.stream(messages) — 流式调用,逐 token 返回这里有两个值得注意的配置:
streaming: true — 开启模型层的流式输出,后面会详细讲thinking — 开启 Extended Thinking,让模型在回复前先进行内部推理,最后一节会展开import { createDeepAgent } from 'deepagents';import { MemorySaver } from '@langchain/langgraph';export const agent = createDeepAgent({model,tools: [calculatorTool, getCurrentTimeTool],systemPrompt: '你是一个乐于助人的 AI 助手。当用户需要进行数学计算或查询时间时,请使用相应的工具来完成。',checkpointer: new MemorySaver(),});
createDeepAgent 创建了一个 LangGraph 图结构的 Agent,内部流程:
[用户消息] → [LLM] → 要调工具?├─ Yes → [执行工具] → 回到 LLM└─ No→ [返回最终回复]
四个参数的含义:
| 参数 | 说明 |
|---|---|
model | ChatModel 实例,Agent 的"大脑" |
tools | 工具数组,Agent 自主选择调用哪个 |
systemPrompt | 系统提示词,定义 Agent 的角色 |
checkpointer | 记忆存储器,MemorySaver 是内存版(重启丢失),生产环境可换 PostgresSaver |
代码在 src/index.ts 中。
LangChain Agent 提供两种调用方式:
agent.invoke() — 等 Agent 完成所有工具调用后才返回完整结果(阻塞式)agent.stream() — 每产生一条消息就立刻 yield 出来(流式)对于一个需要调用工具的 Agent,invoke 可能要等好几秒才有输出。而 stream 能让用户实时看到 AI 在"打字",体验完全不同。
使用 stream() 配合 streamMode: 'messages',每次 yield 的是 [message, metadata] 元组:
const stream = await agent.stream({ messages: [{ role: 'user', content: '帮我计算 128 乘以 47 等于多少' }] },{ ...config, streamMode: 'messages' },);for await (const [message] of stream) {console.log(message);// 会看到各种类型的消息}
message 是 LangChain 的消息对象,通过 message._getType() 判断类型:
| 类型 | 含义 |
|---|---|
'ai' | LLM 的输出(可能包含文本 + tool_call) |
'tool' | 工具执行后的返回结果 |
'human' | 用户消息(stream 中一般不出现) |
AI 消息的 content 字段有两种形态,这是个容易踩坑的地方:
形态一:字符串(没有调用工具时的纯文本回复)
message.content === "128 × 47 = 6016"
形态二:数组(调用了工具时,包含多种 block)
message.content === [{ type: 'text', text: '计算结果是...' },{ type: 'tool_use', id: '...', name: 'calculator', input: {...} },]
所以处理流式消息时,两种情况都要兼顾:
async function printStream(stream: AsyncIterable<[any, any]>) {for await (const [message] of stream) {// 只处理 AI 消息,跳过 tool / humanif (message?._getType?.() === 'ai') {// 情况 1:content 是字符串if (typeof message.content === 'string' && message.content) {process.stdout.write(message.content);}// 情况 2:content 是数组,遍历找 text blockelse if (Array.isArray(message.content)) {for (const block of message.content) {if (block.type === 'text' && block.text) {process.stdout.write(block.text);}}}}}}
普通 LLM 每次调用都是独立的,它不记得你上一句说了什么。Agent 通过 checkpointer(记忆存储器)解决这个问题。
const config = { configurable: { thread_id: 'session-1' } };
MemorySaver 按 thread_id 存储对话历史。同一个 thread_id 的所有消息会被累积存储,Agent 每次调用时都能看到之前的完整对话。
// 第一轮:计算器await agent.stream({ messages: [{ role: 'user', content: '帮我计算 128 乘以 47 等于多少' }] },{ ...config, streamMode: 'messages' },);// Agent 回复:"128 × 47 = 6016"// 第二轮:故意质疑await agent.stream({ messages: [{ role: 'user', content: '算的不对吧' }] },{ ...config, streamMode: 'messages' },);// Agent 会回顾之前的计算,重新审视结果// 因为它"记得"上一轮自己算了什么
第二轮的"算的不对吧"没有提供任何数字信息,但 Agent 能理解这是在质疑上一轮的计算结果,这就是 thread_id + MemorySaver 的效果。
这是最后加上的一个进阶功能——让模型在给出最终答案之前,先展示它的内部推理过程。
Extended Thinking 是 Anthropic 提供的一个能力:模型在生成最终回复之前,会先进行一段"内心独白"(thinking),展示它是怎么一步步推理的。
对于用户来说,这就像一个"透明窗口"——你能看到 AI 在"想什么",而不只是看到最终答案。
在 ChatAnthropic 配置中加入 thinking 参数:
const model = new ChatAnthropic({model: 'qwen3.7-plus',// ...maxTokens: 10000, // thinking 模式下必须显式设置thinking: {type: 'enabled',budget_tokens: 5000,// thinking 阶段最多消耗 5000 token},});
注意:开启 thinking 后,maxTokens 必须显式设置,这是 Anthropic API 的硬性要求。
开启 thinking 后,AI 消息的 content 数组中会多出一种 block 类型:
message.content === [{ type: 'thinking', thinking: '用户让我算 128 × 47,我需要用计算器工具...' },{ type: 'tool_use', ... },{ type: 'text', text: '128 × 47 = 6016' },]
在 printStream 中新增对 thinking block 的处理:
for (const block of message.content) {// thinking block:模型的内部推理(用灰色显示)if (block.type === 'thinking' && block.thinking) {process.stdout.write(`x1b[90m[思考] ${block.thinking}x1b[0m`);}// text block:模型的最终文本回复if (block.type === 'text' && block.text) {process.stdout.write(block.text);}}
x1b[90m 是 ANSI 转义码,让 thinking 内容以灰色显示,和最终的文本回复在视觉上区分开。
--- 测试 1:计算器工具 ---用户:帮我计算 128 乘以 47 等于多少助手:[思考] 用户想要计算 128 乘以 47,这是一个乘法运算,我应该使用 calculator 工具...128 × 47 = 6016
灰色部分是模型的推理过程,正常颜色是最终答案。
从一个空文件夹开始,一步步搭建了:
pnpm init + tsconfig.json + tsx 开发环境createDeepAgent 把 LLM + 工具 + 循环串起来agent.stream() + streamMode: 'messages',踩过了 content 类型的坑thread_id + MemorySaver 实现上下文记忆pnpm dev
输出三个测试场景:计算器工具调用、多轮对话上下文记忆、时间工具调用。
MemorySaver 换成持久化存储Agent 的世界刚刚打开,这个项目只是一个起点。