手写 Agent 循环 vs LangGraph:框架到底替我做了什么
上一篇《前端也能搞懂 Agent:从 Function Calling 到自主编排》里,我用一个
while循环手写了 Agent。写完我说过一句话:框架无非是把这个循环封装了一下。 这篇我要检验这句话——把同一个数据治理 Agent 用 LangGraph.js 重写一遍,和手写版逐行对账:哪些是换了个名字的老朋友,哪些是手写版真做不到的。
🎯 本文目标
读完你应该能:拿着一张"手写代码 ↔ LangGraph 术语"对照表,把 StateGraph、reducer、ConditionalEdge 这些新词全部翻译回你早就会写的 JS;踩过一个从命令式到声明式的思维坑;最重要的——有一条自己的判断标准:什么时候手写就够,什么时候该上框架。
写在前面:为什么"先手写、再用框架"
大多数人学 LangGraph 的路径是:看文档 → 撞上 StateGraph、Annotation、addConditionalEdges 一堆新词 → 云里雾里抄 Demo → 被问"这个 Node 到底在干嘛"就露馅。
我走的是反过来的路:先手写了一版 Agent 循环(上一篇的 agent.js),再用框架重写。好处这次体会得非常具体——框架的每个术语,我都能指着自己手写的某一行说"哦,就是它"。这篇文章就是这份"对账单"。
先把上一篇的核心代码贴回来,后面全文都要对着它讲:
// agent.js —— 上一篇手写的 Agent 主循环
for (let step = 1; step <= MAX_STEPS; step++) {
const reply = await chatWithTools(messages, toolSchemas) // ① 问 AI
messages.push(reply)
if (!reply.tool_calls?.length) { // ② 没工具调用 = 收工
return { answer: reply.content, steps: step }
}
for (const call of reply.tool_calls) { // ③ 执行工具
const args = JSON.parse(call.function.arguments)
const result = await toolExecutors[name]?.(args)
messages.push({ role: 'tool', tool_call_id: call.id, content: String(result) })
}
} // ④ 回到圈顶🗺️ 第 1 章:对账表——LangGraph 的所有新词,都是你写过的旧代码
🎯 本章目标
先把"翻译词典"整个摆出来。记住这张表,后面的代码你就不是在学新东西,是在认老朋友。
| 手写版里的东西 | LangGraph 的叫法 | 一句话对应 |
|---|---|---|
messages 数组(一路 push) | State + MessagesAnnotation | 循环里那个不断累积的东西,框架让你显式声明它 |
messages.push(...) | reducer(状态更新规则) | “新消息怎么并进老状态”——手写版由你直接改数组,框架版由 reducer 统一合并节点返回的更新 |
chatWithTools(...) 那一步 | Node(节点)agent | 问模型、拿回复,图里的一个格子 |
for (const call...) 执行工具那段 | Node tools(内置 ToolNode) | 执行工具、回填结果,又一个格子 |
if (!reply.tool_calls) return | Conditional Edge(条件边) | "有工具调用去 tools,没有就结束"——你写的是 if,框架画成带判断的箭头 |
for 循环回到圈顶 | Edge:tools → agent | 工具执行完,箭头指回 agent 再问一轮 |
MAX_STEPS 上限 | recursionLimit(默认 25) | 防死循环,一模一样的东西 |
整个 runAgent 函数 | .compile() 的产物 | 拼好节点和边,框架把"循环怎么转"生成出来 |
看出规律了吗?表的左列是"循环怎么转"(命令式),右列是"零件怎么连"(声明式)。 同一台机器,两种画法:
┌─────────┐
│ START │
└────┬────┘
▼
┌─────────┐ 有 tool_calls ┌─────────┐
┌───▶│ agent │──────────────────▶│ tools │
│ └────┬────┘ └────┬────┘
│ │ 没有 tool_calls │
│ ▼ │
│ ┌─────────┐ │
│ │ END │ │
│ └─────────┘ │
└────────────────────────────────────────┘
(tools 执行完指回 agent,就是手写版的"回到圈顶")这张图到底是什么?拿一次真实执行,两边各走一遍
如果看着图还是有点懵——它其实就是把开头那段 for 循环画成了流程图。用一个具体问题走一遍:"amount 含税吗?帮我写查销售额的 SQL"。
手写版的执行过程(顺着 for 循环走):
第 1 圈:问 AI → AI 说"我要调 search_metadata"(有 tool_calls)
→ 走进内层 for,执行工具、把结果 push 进 messages → 回到圈顶
第 2 圈:问 AI → AI 说"我要调 generate_sql"(有 tool_calls)
→ 执行工具 → 回到圈顶
第 3 圈:问 AI → AI 给出最终回答(没有 tool_calls)→ 命中 if,return 收工同一次执行,在图上走:
START → agent →(有 tool_calls?有)→ tools → 回到 agent
→(有?还有)→ tools → 回到 agent
→(有?没了)→ END一模一样的三步。agent 格子 = "问 AI"那行代码;tools 格子 = 内层 for 那段;带问号的箭头 = 那句 if;tools 指回 agent 的箭头 = "回到圈顶"。
那"两种画法"到底差在哪?
- for 循环描述的是时间顺序:先干什么、再干什么、什么条件跳出——你在写"循环怎么转"。
- 图描述的是一张地图:有哪几个站点(节点)、站点间有哪些路(边)、岔路口按什么规则选(条件边)——你只画"零件怎么连",至于"沿着地图跑起来",LangGraph 的
compile()替你做。
打个前端的比方:手写循环像你用 history.pushState + 一堆 if 自己控制页面跳转;图像 Vue Router 的路由表——你只声明"哪个路径对应哪个页面、什么守卫条件跳哪","跳转"本身框架来跑。路由表,就是那张图。
🧭 承上启下
词典有了,图也有了。现在动手把手写版翻译成 LangGraph——你会发现代码量没少多少,但写的东西完全变了。
🔧 第 2 章:同一个 Agent,LangGraph 写一遍
🎯 本章目标
拿到能跑的 LangGraph 版本,和手写版并排对比,看清"你写的代码"从"循环"变成了"零件"。
2.1 工具:从"schema + executor 分两半"到 tool() 合一
手写版里,工具的 JSON Schema(给模型看的说明书)和执行函数(真正干活的)是分开维护的两半,还要自己 JSON.parse(arguments) 再校验。LangGraph 生态用 tool() 把它们合成一个对象,参数校验交给 Zod:
// lg-tools.js —— 工具定义(执行体复用之前 RAG 系列写好的能力,一行业务逻辑不新写)
import 'dotenv/config'
import { tool } from '@langchain/core/tools'
import { z } from 'zod'
import { buildHybridSearcher, answerWithHybrid } from './metadata-qa-hybrid.js'
import { generateSQL } from './text-to-sql.js'
const searcher = await buildHybridSearcher() // 检索器建一次,多工具共用
export const searchMetadata = tool(
async ({ query }) => {
const { text } = await answerWithHybrid(searcher, query)
return text
},
{
name: 'search_metadata',
description: '检索数据仓库的表结构、字段口径。需要知道某字段/表含义、是否含税时调用。',
schema: z.object({ query: z.string().describe('要查的字段名、表名或问题') }),
}
)
export const generateSqlTool = tool(
async ({ question }) => {
const { sql } = await generateSQL(searcher, question)
return sql
},
{
name: 'generate_sql',
description: '根据真实表结构生成参考 SQL。用户想查询/统计某些数据时调用。',
schema: z.object({ question: z.string().describe("查询需求,如'最近30天每天的销售额'") }),
}
)
export const tools = [searchMetadata, generateSqlTool]注意那个 z.object({...}):手写版里"给模型的 schema"和"执行前的参数校验"是两件事,这里一句话同时搞定——模型吐的参数不合法,Zod 直接拦下,JSON.parse + try/catch 那段代码消失了。
2.2 组装图:手写版的循环,变成节点和箭头
// lg-agent.js —— 用 LangGraph 重写手写版 Agent
// 跑:node scripts/lg-agent.js "dw_orders 怎么查最近30天销售额?amount 含税吗"
import 'dotenv/config' // ← 必须第一行:ChatOpenAI 在模块加载时就读 process.env
import { StateGraph, MessagesAnnotation, START, END } from '@langchain/langgraph'
import { ToolNode } from '@langchain/langgraph/prebuilt'
import { ChatOpenAI } from '@langchain/openai'
import { SystemMessage } from '@langchain/core/messages'
import { tools } from './lg-tools.js'
// 1) 模型:还是硅基流动,bindTools = 手写版请求里带 toolSchemas
const model = new ChatOpenAI({
model: 'deepseek-ai/DeepSeek-V3',
apiKey: process.env.SILICONFLOW_API_KEY,
configuration: { baseURL: 'https://api.siliconflow.cn/v1' },
temperature: 0.3,
}).bindTools(tools)
const SYSTEM = new SystemMessage(`你是一个数据治理助手,可以调用工具完成任务:
- 查字段/表含义、是否含税 → search_metadata
- 写查询 SQL → generate_sql
可以分多步调用工具。拿到足够信息后,用中文给用户完整、简洁的最终回答。`)
// 2) agent 节点 = 手写版的 chatWithTools 那一步
async function callAgent(state) {
const reply = await model.invoke([SYSTEM, ...state.messages])
return { messages: [reply] } // 返回增量!reducer 负责 concat(第 3 章专门讲这里的坑)
}
// 3) tools 节点 = 手写版的 for(const call...) 整段,框架内置了
const toolNode = new ToolNode(tools)
// 4) 路由函数 = 手写版的 if (!reply.tool_calls) return
function shouldContinue(state) {
const last = state.messages.at(-1)
return last.tool_calls?.length ? 'tools' : END
}
// 5) 拼图 + compile = 手写版的 runAgent 被自动生成
const graph = new StateGraph(MessagesAnnotation)
.addNode('agent', callAgent)
.addNode('tools', toolNode)
.addEdge(START, 'agent')
.addConditionalEdges('agent', shouldContinue, { tools: 'tools', [END]: END })
.addEdge('tools', 'agent')
.compile()
const q = process.argv.slice(2).join(' ')
|| 'dw_orders 表怎么查最近30天的销售额?顺便确认下 amount 含不含税'
const result = await graph.invoke({ messages: [{ role: 'user', content: q }] })
console.log('\n最终回答:\n', result.messages.at(-1).content)用同一问题跑出来,两版工具调用顺序一致:
手写版(agent.js) LangGraph 版(lg-agent.js)
───────────────── ─────────────────────────
第 1 步:search_metadata(查表结构) [agent] 调 search_metadata
第 1 步:search_metadata(查 amount 含义) [tools] 返回字段口径
第 2 步:generate_sql(写查询 SQL) [agent] 调 generate_sql
最终回答:给出 SQL + 含税说明 [tools] 返回 SQL 脚本
[agent] 最终回答两版都识别了”先查口径→再写 SQL”的合理编排顺序,最终结论都指向 amount 字段口径并给出了参考 SQL——虽然具体措辞因模型采样而不同,但语义一致。验收标准达标。
但更值得注意的不是输出,而是你写的东西变了:没有 for 循环、没有手写的 tool_calls 遍历和回填、没有显式的 MAX_STEPS——那些全被”图 + ToolNode + recursionLimit”吸收了。你现在写的是”零件怎么连”,不是”循环怎么转”。
🤔 防死循环去哪了?
手写版的 MAX_STEPS,LangGraph 内置成 recursionLimit(默认 25 步),超了自动抛错。要改就 graph.invoke(input, { recursionLimit: 10 })。同一个东西,换了个名字。
🧭 承上启下
翻译工作到此顺利得可疑——好像真的只是"换了套词汇"。但有一个地方,我按手写版的直觉去写,立刻就错了。这个坑值得单独一章。
🕳️ 第 3 章:唯一真正的新东西——从"push"到"返回增量"
🎯 这一章要回答
翻译过程里,哪一步不是"换名字",而是"换脑子"?
按老直觉写,契约就混了
手写版里我更新状态的方式是命令式的:拿到新消息,messages.push(reply),完事。翻译到 LangGraph 的 agent 节点时,手很自然地想这么写:
// ❌ 不要这样写:既修改输入 state,又返回整份历史
async function callAgent(state) {
const reply = await model.invoke(state.messages)
state.messages.push(reply) // 把框架传进来的状态直接改了
return { messages: state.messages } // 又把“旧历史 + 新回复”当成节点更新返回
}我按老直觉写出来,跑了一轮——没有报错,没有消息重复。框架的 reducer 按消息 ID 做了去重,让表面看起来”暂时没事”。但问题比报错更隐蔽:节点同时扮演了”修改全局状态的人”和”报告本步产出的人”,破坏了框架的状态更新契约。 一旦出现重试、并行节点、状态回放或消息删除,这种副作用会让行为难以推断。框架把”怎么合并”交给 reducer,节点就不该再直接改传入的 state。
正确姿势:Node 只报告“我产出了什么”
// ✅ 声明式:只返回增量,合并交给 reducer
async function callAgent(state) {
const reply = await model.invoke(state.messages)
return { messages: [reply] } // 只说"我这步产出了这条新消息"
}对照前端的概念,这个味道你其实很熟——Redux:你从来不在 reducer 外面直接改 store,而是 dispatch 一个"发生了什么",由 reducer 决定新状态长什么样。LangGraph 的 Node 就是 dispatch 的那一方,reducer 是合并规则:
| Redux | LangGraph | |
|---|---|---|
| 谁产出变化 | action("发生了什么") | Node 返回的增量("我产出了什么") |
| 谁负责合并 | reducer | State 声明里的 reducer |
| 大忌 | 直接 mutate store | 在 Node 里直接 push state |
如果只为了理解 reducer,可以先把它近似想成下面这条规则:
const AgentState = Annotation.Root({
messages: Annotation({
reducer: (old, next) => old.concat(next), // 心智模型:旧消息 + 本节点新产出的消息
default: () => [],
}),
})但要注意,这只是帮助理解的简化版,不是 MessagesAnnotation 的完整源码。真实的消息 reducer 还会处理消息对象转换、按 ID 更新和删除等语义。这里真正要记住的不是 concat 这一个实现细节,而是:Node 返回更新,reducer 决定如何把更新并入 State。
💡 一句话总结
这是整个迁移里唯一需要"换脑子"的地方:手写版里"改状态"是你的事,LangGraph 里"改状态"是 reducer 的事,Node 只负责报告增量。 其余所有概念都是换名字,只有这一个是换思维——而它恰恰是 Redux 早就教过前端的那一课。
🧭 承上启下
到这里,"框架替我做了什么"已经对完账:封装了工具回填、把 if 画成边、把 push 声明成 reducer。说实话,如果只看这些——手写版一点都不亏。那框架的真正回报在哪?在三个手写版做不到(或做起来很丑)的地方。
⚖️ 第 4 章:手写循环的三个天花板——这才是选型的分界线
🎯 本章目标
诚实回答"手写都能跑,为什么要框架"。答案不在跑通的部分,在跑通之后。
上一篇我说"Agent = Function Calling + 循环",这个结论没错,但它隐含了一个边界:它描述的是 Demo 阶段的 Agent。真要把 Agent 做成产品,手写循环会撞上三个天花板:
天花板一:流程一复杂,if/else 就爆炸
手写版只有一个判断:有没有 tool_calls。但真实 Agent 经常是:查完元数据 → 判断字段够不够 → 够就写 SQL、不够先反问用户 → SQL 报错还要回退重写。这些分支全塞进一个 for 循环,很快变成没人敢动的意大利面。图的价值是每个分支都是一条画得出来的边,流程一眼可见——加一个分支是加一条边,不是往循环里再嵌一层 if。
天花板二:状态是隐式的,出了问题靠脑补
手写版的 messages 是个裸数组,谁在哪一步 push 了什么,出问题只能加 console.log 逐步还原。LangGraph 把 State 显式声明成 schema,每个 Node 的输入输出清清楚楚,配上 .stream() 能逐节点打印执行轨迹。用 streamMode: 'updates' 跑一次,输出是这样的:
[agent] 决定调工具: search_metadata
[tools] 返回字段口径:amount 为税后金额…
[agent] 决定调工具: generate_sql
[tools] 返回 SQL 脚本
[agent] 最终回答每一行就是一个节点的执行结果。不用猜”AI 走到哪一步了”——直接用眼睛看。这正是 RAG 踩坑实录里那条方法论——“把中间状态打印出来,用眼睛看”——被框架做成了一等公民。
天花板三:持久化 / 中断,手写要重造轮子
想让对话"关掉明天还能接着聊"?手写版得自己设计存储、序列化 messages、读回来拼。想在 Agent 执行危险操作前暂停等人确认?往 for 循环里插这个逻辑非常别扭。LangGraph 的 Checkpointer 把这两件事做成了配置项——compile({ checkpointer }) 一行,状态自动存档,thread_id 一带就恢复。
所以,我的选型标准
| 场景 | 选择 | 理由 |
|---|---|---|
| 学原理、面试演示、一次性脚本 | 手写循环 | 40 行代码零依赖,每一行都讲得清 |
| 流程只有"调工具→回答"一层判断 | 手写循环 | 框架的图结构在这里是杀鸡用牛刀 |
| 流程有多个分支 / 要回退重试 | LangGraph | 边比嵌套 if 可维护,加分支不动存量代码 |
| 要多轮记忆、断点恢复、人工确认 | LangGraph | Checkpointer / interrupt 是内置的,手写等于重造轮子 |
| 要观测每一步在干嘛 | LangGraph | .stream() 逐节点吐轨迹,不用自己埋 log |
🎁 结语:框架没有魔法,但"知道它替你做了什么"就是护城河
对完这笔账,回到开头那句话——"框架无非是把循环封装了一下"。现在可以给出更精确的版本:
💎 只记一句话也够
LangGraph 把我手写的"for 循环 + if 判断 + messages.push",形式化成了"节点 + 条件边 + reducer"。跑通一个 Demo,这层形式化是纯开销;但当流程长出分支、状态需要存档、执行需要人把关时,"图"比"一坨 for 循环"能多活好几个版本。
先手写、再用框架,这条路多花了我几天,但换来的东西很实在:createReactAgent 这种一行生成整张图的语法糖,我知道它内部就是第 2 章我手搭的那张图;面对"这个 Node 到底在干嘛"的追问,我能指着手写版的某一行回答。调库和懂框架的分水岭,就是能不能说清"它替我做了什么"。
不过话说回来,"分水岭"这个标准对框架自己也适用。第 4 章我夸 Checkpointer"一行配置就有多轮记忆"——但你想过没有:那行配置背后,状态到底被存哪了?我没细想就用了,结果狠狠踩了一坑:服务一重启,所有用户的对话记忆全没了。这个坑怎么踩的、生产上该怎么填,我写在了下一篇《你的 AI 应用一发版,用户记忆全没了》。