手写一个最小版 Claude Code:从任务拆解到 Agent Loop 转起来

0 阅读5分钟

写在前面

你说"帮我建个 react+vite 的 todolist",Claude Code 自己就把项目搭好、代码写好、npm run dev 跑起来。

看起来像魔法,拆开看就是一句话:LLM + Tool(fs + cli)

今天咱们照着这个思路,把第一个能跑的编程 Agent 从任务规划、工作流编排、Message 协议、到 while 循环转起来,全流程串一遍。

Demo 拆任务:一个三步走的规划

Agent 接到"创建 react+vite todolist"这个任务,第一件事不是写代码,是规划

js

// 任务:创建一个 react+vite 的 todolist

LLM 拆成三步,每步对应一个工具:

步骤干啥需要的 Tool
1vite 创建项目脚手架写入文件 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 里节点之间的连线:

ChatOpenAI(模型)
   ↓
tools(声明工具,async fn + zod schema)
   ↓
bindTools(把工具绑到模型上,切到"工具模式")
   ↓
invoke(喂 messages,跑起来)

bindTools 这一步是关键开关——绑了工具,LLM 才知道"这事我能动手",否则它只会给你回一段伪代码。

4 种 Message:Agent 的对话协议

Agent 跟 LLM 通信靠 messages 数组,LangChain 给消息分了四种派生类:

Message 类型谁说的装什么关键字段
SystemMessage开发者设定AI 是谁、能干啥、行为规范系统提示词
HumanMessage用户任务指令用户输入
AIMessageLLM推理结果 + 工具调用意图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 提到顶层,方便你直接遍历。

维度原生 OpenAILangChain
工具调用位置additional_kwargs.tools顶层 tool_calls + 保留 kwargs
工程便捷性自己解析框架帮你准备好
可读性一般
切厂商成本重写改一行配置

这就是"框架"的价值——不是它做了你做不了的事,是它把要做的事做得更顺手、更可读、更可维护。

最简 Agent Loop:while 循环转起来

前面都是零件,真正让 Agent 活过来的是这个循环

js

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 看着强大,拆到底也是这套结构,只是工具更全、工程化更狠。