写在前面
你说"帮我建个 react+vite 的 todolist",Claude Code 自己就把项目搭好、代码写好、npm run dev 跑起来。
看起来像魔法,拆开看就是一句话:LLM + Tool(fs + cli) 。
今天咱们照着这个思路,把第一个能跑的编程 Agent 从任务规划、工作流编排、Message 协议、到 while 循环转起来,全流程串一遍。
Demo 拆任务:一个三步走的规划
Agent 接到"创建 react+vite todolist"这个任务,第一件事不是写代码,是规划。
js
arduino
// 任务:创建一个 react+vite 的 todolist
LLM 拆成三步,每步对应一个工具:
| 步骤 | 干啥 | 需要的 Tool |
|---|---|---|
| 1 | vite 创建项目脚手架 | 写入文件 Tool(落地 package.json、vite.config) |
| 2 | 编写组件、样式、逻辑 | 写入文件 Tool(落地 App.jsx、TodoList.jsx) |
| 3 | 装依赖 + 跑起来 | CLI 命令 Tool(npm install + npm run dev) |
这叫 planning ------LLM 自己拆任务、自己决定每一步用哪个工具。一个最小版 Claude Code 的工具盘就是 fs(读写文件)+ cli(执行命令),两个工具撑起整个编码 Agent。
LangChain:LLM 界的工作流编排框架
LangChain 比 OpenAI SDK 还早出生,定位很清楚:LLM 应用开发框架。
它解决一个核心痛点------LLM 厂商太多,今天用 OpenAI、明天切 DeepSeek、后天换 Qwen,难道每换一家就重写一遍?
LangChain 用一套统一抽象把它们接进来,@langchain/openai 只是一个适配器,换厂商改一行配置就完事。
工作流的编排像搭积木,也像 Coze 里节点之间的连线:
rust
ChatOpenAI(模型)
↓
tools(声明工具,async fn + zod schema)
↓
bindTools(把工具绑到模型上,切到"工具模式")
↓
invoke(喂 messages,跑起来)
bindTools 这一步是关键开关------绑了工具,LLM 才知道"这事我能动手",否则它只会给你回一段伪代码。
4 种 Message:Agent 的对话协议
Agent 跟 LLM 通信靠 messages 数组,LangChain 给消息分了四种派生类:
| Message 类型 | 谁说的 | 装什么 | 关键字段 |
|---|---|---|---|
| SystemMessage | 开发者设定 | AI 是谁、能干啥、行为规范 | 系统提示词 |
| HumanMessage | 用户 | 任务指令 | 用户输入 |
| AIMessage | LLM | 推理结果 + 工具调用意图 | tool_calls |
| ToolMessage | 你的代码 | 工具执行结果 | tool_call_id |
tool_call_id 是这一轮的身份证。LLM 一次可能调多个工具,每个 tool_calls 带 id,工具跑完回传 ToolMessage 时必须带上同一个 id------LLM 靠它对账"这个结果是我刚才哪个调用产生的"。
四种 Message 在数组里按时间顺序排好,就是 Agent 的完整对话上下文。
原生 OpenAI vs LangChain:返回差异
原生 OpenAI SDK 返回的工具调用塞在 additional_kwargs.tools 里,裸奔状态,你自己抠。
LangChain 的 invoke 原样保留了这些信息,还贴心地把 tool_calls 提到顶层,方便你直接遍历。
| 维度 | 原生 OpenAI | LangChain |
|---|---|---|
| 工具调用位置 | additional_kwargs.tools |
顶层 tool_calls + 保留 kwargs |
| 工程便捷性 | 自己解析 | 框架帮你准备好 |
| 可读性 | 一般 | 高 |
| 切厂商成本 | 重写 | 改一行配置 |
这就是"框架"的价值------不是它做了你做不了的事,是它把要做的事做得更顺手、更可读、更可维护。
最简 Agent Loop:while 循环转起来
前面都是零件,真正让 Agent 活过来的是这个循环:
js
javascript
let messages = [
new SystemMessage('你是代码助手,可用工具:write_file、run_cli'),
new HumanMessage('帮我创建一个 react+vite 的 todolist'),
];
// 最简单的 loop
while (true) {
const response = await modelWithTools.invoke(messages);
messages.push(response);
// 没有 tool_calls → LLM 已经拿到结果,准备最终回答
if (!response.tool_calls?.length) {
break; // 循环退出,任务完成
}
// 有 tool_calls → 并行执行所有工具,结果回喂
const toolResults = await Promise.all(
response.tool_calls.map(call => tools[call.name].invoke(call.args))
);
toolResults.forEach((result, i) => {
messages.push(new ToolMessage({
tool_call_id: response.tool_calls[i].id,
content: result,
}));
});
}
逐段拆解:
| 段落 | 干啥 | 为什么这么写 |
|---|---|---|
while(true) |
持续运转 | Agent 就是循环,不到完成不停 |
invoke + push |
每轮都把 AI 回复塞回数组 | LLM stateless,靠 messages 数组维持上下文 |
if (!tool_calls) |
没有工具调用就退出 | LLM 觉得"够了"才会直接给答案 |
Promise.all(map) |
并行跑所有工具 | 一轮可能调多个工具,串行太慢 |
ToolMessage + tool_call_id |
结果回喂 | 让 LLM 对上号 |
这就是笔记里说的"最简单的 loop 有工具调用"------有就继续转,没有就最后一次 invoke 拿结果。
async/Promise 在 Agent 里的位置
整个 Agent 几乎全是 async 函数,因为每一步都在等------等 LLM、等工具、等文件 IO。
几个要点记一下:
| 特性 | 在 Agent 里的用法 |
|---|---|
async 函数 = Promise 实例 |
整个 main() 是 async,return 的值就是 resolve 的值 |
await |
等 LLM 回复、等工具跑完 |
Promise.all |
一轮多工具并行,谁也别等谁 |
Array.find/map |
在 tools 数组里按 name 找工具、map 出工具结果 |
try/catch |
工具可能挂(文件不存在、CLI 报错),必须兜底 |
特别提一句 tools[call.name] 这种写法------把工具做成一个 name 到函数的 map,LLM 吐 call.name,你直接查表执行。比 if/else 一长串判断干净多了。
5 个踩坑提醒
1. 忘了把 response push 回 messages。 LLM 是 stateless 的,上一轮它说了啥它自己不知道。不把 AI 回复塞回数组,下一轮它就"失忆",循环直接乱套。
2. ToolMessage 不带 tool_call_id。 多工具并行时,LLM 靠 id 对账。丢了 id,结果成了无头尸,LLM 不知道这个结果对应哪次调用。
3. while 没有退出保护。 万一 LLM 一直吐 tool_calls,循环永远不退。加个最大轮次计数器,到上限强制 break。
4. 工具执行不 try/catch。 文件不存在、CLI 报错,Promise 直接 reject,整个 Agent 崩。每个工具内部包 try/catch,把错误信息当结果回喂给 LLM,让它自己决定重试还是换思路。
5. async 函数当同步用。 const result = someAsyncTool(args) 不 await,拿到的是 Promise 不是数据。Agent 里到处是坑,养成"async 必 await"的肌肉记忆。
写在最后
一个能跑的编程 Agent,核心就这些:LLM 拆任务 → LangChain 编排工具 → 4 种 Message 维持上下文 → while 循环转起来。
零件不多,难的是把它们接得稳、转得稳。Claude Code 看着强大,拆到底也是这套结构,只是工具更全、工程化更狠。