为了搞懂 AI Agent,我用 6000 行 JS 代码手搓了一个零依赖的 Coding Agent

先说结论

这阵子大家都在用 Claude Code、Cursor、Codex,体验是真香,但用着用着我心里总有个疙瘩:

这玩意儿到底是不是黑魔法?

它怎么知道该去读哪个文件?它改我代码之前那个 diff 是怎么来的?我按 Ctrl-C 它怎么就能稳稳地停下来、还不把对话搞乱?流式输出那一个字一个字往外蹦的效果,背后到底发生了什么?

为了把这些问号一个个抠掉,我干脆自己撸了一个 ------ 一个零运行时依赖、克隆下来就能跑的命令行 Coding Agent。约束我给得很死:

  • package.jsondependenciesdevDependencies 必须是空的,所有功能只能用 Node 标准库;
  • 用户只需要 git clone + export OPENAI_API_KEY=... + node mini-agent.js,没有构建、没有 TypeScript 编译、没有打包器;
  • 兼容 Node 16+,连内置 fetch 都不用(那是 Node 18+ 才稳的),老老实实用 http/https/fs/child_process/readline

写完一数,6193 行、38 个模块。麻雀虽小,五脏俱全:工具调用、SSE 流式、Ctrl-C 中断、改文件回退、@ 文件引用、上下文自动压缩、会话持久化,甚至还有个能在浏览器里 Alt+Click 改样式的 Inspector。

这篇文章不灌水,我直接把里面最硬的几块掏出来给你看。看完你大概率会有跟我一样的感受:Coding Agent 没有黑魔法,但每个细节都藏着魔鬼。


一、Agent Loop 是心脏,本质上就是一个 while 循环

很多人以为 Agent 是什么很高深的东西。其实剥到最里面,它就是一个循环:

把对话发给 LLM → 看它要不要调工具 → 要就调,把结果塞回去 → 不要就说明它答完了 → 退出。

直接上代码,这是整个项目的"心脏"agentTurn,我删掉了注释让你先看骨架:

ini 复制代码
async function agentTurn(messages, opts) {
  const tools = getTools();
  for (let step = 0; step < MAX_STEPS; step++) {
    // 1) 流式发给 LLM,实时把文字打到终端
    const llmResult = await callLLMStreaming(messages, tools, { ...opts, stepIndex: step });
    if (llmResult.aborted) return;            // 用户 Ctrl-C 了,收工
​
    const msg = llmResult.resp.message;
    messages.push(msg);
​
    // 2) 没有 tool_calls,说明模型已经在跟你聊天了 → 本轮结束
    if (!msg.tool_calls || msg.tool_calls.length === 0) {
      // ...收尾、持久化、可能触发上下文压缩
      return;
    }
​
    // 3) 有 tool_calls:执行它们,把结果回填进 messages,然后继续下一轮
    await runScheduledToolCalls(msg.tool_calls, messages);
  }
  console.log('(已达到最大步数,停止本轮)');
}

就这么点东西。你品一品:

  • for 循环 + MAX_STEPS 上限:这不是为了好看,是保命用的。万一模型抽风,反复调工具不收手,没这个上限它能把你的 token 烧穿。
  • 有没有 tool_calls 决定循环要不要继续 :这是 OpenAI Chat Completions 协议的核心交互。模型回的消息里如果带了 tool_calls,意思就是"我现在还干不了活,你先帮我执行这几个工具";不带,就是"我说完了"。
  • messages 是个会被反复 mutate 的数组:整个对话历史就活在这个数组里,每一轮 LLM 的回复、每一个工具的执行结果,都按顺序往里 push。Agent 的"记忆"就是它。

理解了这个循环,你就理解了 80% 的 Coding Agent。剩下 20%,全在细节里 ------ 而细节才是真正区分"能跑"和"好用"的地方。


二、流式输出:那个"打字机效果"是怎么来的

我没用 openai 那个 npm 包(零依赖嘛),所以 SSE 流式解析得自己写。写之前我以为很简单,写完发现踩了三个坑,每个都够喝一壶。

坑 1:SSE 分帧 ------ 一个事件可能被切成好几段

服务端用 stream: true 返回的是 Server-Sent Events,事件和事件之间用 \n\n 分隔。但你从 TCP 上收到的 chunk 根本不保证按事件边界来:一个事件可能被切成两个 chunk,两个事件也可能挤在一个 chunk 里。

所以得维护一个缓冲区,攒着、按 \n\n 切,切剩下的残段留给下一次:

ini 复制代码
function parseSSEFrames(buffer) {
  const frames = buffer.split('\n\n');
  const rest = frames.pop() || '';   // 最后一段大概率不完整,留着
  return { frames, rest };
}
​
// 在 res.on('data') 里:
sseBuffer += chunk;
const { frames, rest } = parseSSEFrames(sseBuffer);
sseBuffer = rest;                    // 残段接着等下一个 chunk

这一步要是偷懒不做,你会随机性地解析出半个 JSON 然后报错,而且复现极其玄学,因为它取决于网络抖动。

坑 2:tool_calls 是"一个字一个字拼出来的"

这个最反直觉。你以为模型要调 edit_file({"path": "a.js", ...}),会一次性把这个 JSON 给你?不。流式接口里,function.arguments 是逐片段到达的,你得自己拼:

dart 复制代码
function accumulateToolCall(buckets, delta) {
  for (const part of delta.tool_calls) {
    const idx = part.index;          // 同一个 index 跨多帧累积
    if (!buckets[idx]) buckets[idx] = { id: '', function: { name: '', arguments: '' } };
    const bucket = buckets[idx];
    if (part.id) bucket.id = part.id;                              // id 一般只在第一帧给
    if (part.function?.name) bucket.function.name += part.function.name;
    if (part.function?.arguments) bucket.function.arguments += part.function.arguments;  // 关键:拼接,不是覆盖!
  }
}

arguments 那行如果你写成 = 而不是 +=,恭喜,你只会拿到 JSON 的最后一个片段,然后 JSON.parse 当场暴毙。

坑 3:reasoning_content 不打印,但必须回传

现在的推理模型(DeepSeek-R1、o1 那一类)会在正式回答前先吐一大段"思维链",字段叫 reasoning_content。这东西有两个反直觉的点:

  1. 它能要 5~30 秒才出第一个正文字符。 如果这期间你屏幕上啥都不显示,光转个 spinner,用户百分百以为卡死了。所以我把思维链用灰色暗调实时打出来,让用户看到"它在想"。
  2. 它必须随 assistant 消息回传给下一轮,否则下一轮 API 直接 400。 因为服务端会校验对话的连贯性,少了思维链它认为你的历史是残缺的。这个坑我是被报错教育之后才懂的。

把这三个坑填平,那个丝滑的打字机效果才算真的稳。上层 agentTurn 拿到回调后,第一个正文 delta 一来就停掉 spinner、打个蓝色的 Agent › 头,视觉上从"等待"无缝切到"在打字":

scss 复制代码
onDelta(chunk) {
  if (!headerPrinted) {
    ensureSpinnerStopped();
    process.stdout.write('\n\x1b[36mAgent ›\x1b[0m ');  // 蓝色前缀,给个视觉边界
    headerPrinted = true;
  }
  mdRenderer.write(chunk);   // 顺手做流式 markdown 着色
  streamBuf += chunk;
}

三、工具调度的小心机:哪些能并发,哪些必须排队

模型一次可能甩给你好几个工具调用。最偷懒的做法是挨个 await 串行跑完。但这样慢,而且没必要 ------ 读文件、搜代码这种只读操作之间根本没有依赖,完全可以一起跑。

可一旦掺进写操作,事情就微妙了。我的做法是把 tool_calls 切成若干批:连续的只读调用合并成一个并发批,每个写调用自己单独一批串行跑。

php 复制代码
function splitIntoBatches(toolCalls) {
  const batches = [];
  let readonlyBatch = [];
  for (const call of toolCalls) {
    if (isReadonlyTool(call.function.name)) {
      readonlyBatch.push(call);          // 只读的先攒着
      continue;
    }
    if (readonlyBatch.length) {          // 碰到写操作,先把攒着的只读批 flush 出去
      batches.push({ concurrent: true, calls: readonlyBatch });
      readonlyBatch = [];
    }
    batches.push({ concurrent: false, calls: [call] });   // 写操作单独一批
  }
  if (readonlyBatch.length) batches.push({ concurrent: true, calls: readonlyBatch });
  return batches;
}

这里有两个我专门踩过的点:

为什么写操作要串行? 不是性能问题,是 stdin 抢占 问题。每个写操作(改文件、跑 shell)都要弹个 (y/N) 让你确认,几个确认提示同时往终端弹,它们会抢同一个标准输入,你根本不知道自己回的 y 是在批准哪一个。串行就没这毛病。

为什么要严格保持原始顺序? 模型给的调用顺序通常是有依赖的,比如 [read_file, edit_file, read_file] ------ 它想先读、再改、再读改完的结果。你要是为了并发把后面那个 read_file 抢到前面去,它就会读到改之前的旧内容,逻辑直接错乱。所以并发只在"连续只读"这个安全区里发生,写操作像一道道栅栏把顺序卡死。

回填结果也有讲究:OpenAI 协议要求 role: 'tool' 的消息必须严格按 tool_calls 的顺序紧跟在对应的 assistant 消息后面,顺序错了照样 400。所以我并发执行、但按原顺序回填:

javascript 复制代码
const batchResults = await Promise.all(batch.calls.map(call => executeSingleCall(call, runToolFn)));
// 注意:按 batch 原顺序回填,不是按谁先跑完
for (const call of batch.calls) {
  const r = batchResults.find(x => x.id === call.id);
  messages.push({ role: 'tool', tool_call_id: call.id, content: r.content });
}

四、安全:模型的输出,本质是"不可信用户输入"

这是我写这个项目期间脑子转得最多的一块。

你得时刻记住一件事:LLM 的输出,安全等级等同于一段来路不明的用户输入。 一次提示注入、一次模型幻觉,它就可能"好心地"去读你的 ~/.ssh/id_rsa,或者执行个 rm -rf。所以工具层不能裸奔。

第一道闸:路径沙箱

所有碰文件系统的工具,在把相对路径转成绝对路径时,都必须过一遍 safeResolve,确保结果一定落在项目根目录这棵子树里:

arduino 复制代码
const ROOT = process.cwd();  // 启动时一次性绑定,之后再 cd 也不改沙箱根
​
function safeResolve(p, root) {
  const base = root || ROOT;
  const abs = path.resolve(base, p || '.');
  // 这一行是精髓
  if (abs !== base && !abs.startsWith(base + path.sep)) {
    throw new Error(`路径越界,只允许操作 ${base} 内的文件: ${p}`);
  }
  return abs;
}

那个判断条件别小看。最容易写错的是直接 abs.startsWith(base) ------ 这会把 /tmp/foobar 误判成在 /tmp/foo 里面(前缀匹配的经典陷阱)。必须拼上 path.sep,才能保证是"刚好等于 base"或"是 base 的真子目录"。

第二道闸:shell 命令的三级闸门

run_shell 是最危险的工具,所以它走"黑/白/灰"三态:

  • 黑名单rm -rf /sudo、fork bomb、curl | sh 这类)→ 直接拒绝,连问都不问;
  • 白名单lsnpm testpytest 这类只读或安全命令)→ 免确认直接跑,但仍然打一行日志,绝不背着你偷偷执行;
  • 灰名单 (其余的)→ 弹确认,你按 y 才跑。
ini 复制代码
const tier = classifyShellCmd(cmd);
if (tier === 'black') return `命令命中黑名单,已拒绝: ${cmd}`;
if (tier === 'white') {
  console.log(`\n[run_shell · 白名单] ${cmd}\n`);
  return runShellInternal(cmd, ...);
}
// 灰名单:问一句
const ok = await confirm('执行以上命令? (y/N) ', false);
if (!ok) return '用户拒绝执行命令。';

实现上还有些容易忽略的细节:用 spawn 而不是 execexecmaxBuffer 限制,长输出会被截断甚至 OOM);超时直接 SIGKILL(都卡死了还客气什么);输出统一包成 JSON 字符串,让模型拿到稳定结构而不用去猜自然语言。

第三道闸:改文件先给你看 diff

edit_file / multi_edit / write_file 这些写操作,落盘前一律先渲染 diff 给你看,你确认了才写。而且每次写之前会把原文件备份进一个 undo 栈(深度 10),/undo 随时弹出回退。这意味着即便模型改错了,你也有"后悔药"。


五、Ctrl-C 没你想的那么简单

我一开始觉得,不就是监听个 SIGINT 嘛。真写起来才发现,同一个 Ctrl-C,在不同时刻应该干完全不同的事

  • 它正在等 LLM 返回时按 → 应该 abort 那个 HTTP 流;
  • 它正在跑一个 shell 子进程时按 → 应该 kill 那个子进程,而不是退出 Agent;
  • 闲着等你输入时按 → 第一次只警告"再按一次退出",1.5 秒内连按两次才真退(防手滑误触)。

如果让每个调用点各自处理 Ctrl-C,逻辑会散得到处都是,还特别容易出现"流 abort 了但监听器没摘干净"这种内存泄漏。所以我把它收口成一个信号状态机,业务层只管报告自己在干嘛:

ini 复制代码
let inFlight = null;   // null(闲着) | 'llm'(等模型) | 'tool'(跑工具)
​
function handleSigint() {
  if (inFlight === 'llm') { abort(); return; }                  // 中断 HTTP 流
  if (inFlight === 'tool' && toolAbortFn) { toolAbortFn(); return; }  // kill 子进程
​
  // 走到这说明在闲着 → 双按退出判定
  const now = Date.now();
  if (lastSigintAt && now - lastSigintAt <= SIGINT_DOUBLE_WINDOW_MS) process.exit(0);
  lastSigintAt = now;
  process.stdout.write('\n(再按一次退出)\n');
}

这里有个我觉得挺有人情味的设计决策:工具执行优先于"双按退出" 。因为如果一个工具正在跑(比如 npm test 跑了半天),用户按 Ctrl-C 的真实意图几乎肯定是"停掉这个测试",而不是"退出整个 Agent"。猜对用户意图,体验才不别扭。

还有一个一次性资源的坑:AbortController 用一次就废了abort() 之后它的 signal.aborted 永远是 true。所以每轮 LLM 调用结束都得换一个新的,不然下一轮请求会被这个"早就中止了"的信号当场拒绝。

而用户中断这件事,在 Agent Loop 里还得"软着陆":不能让异常直接冒到 REPL 把界面搞崩,而是把已经吐出来的内容拼上一个 [用户中断] 塞进历史,保证对话记录是连贯的 ------ 哪怕一个字没出,只要有思维链,也得带上,否则下一轮 API 又会因为"孤儿消息"翻脸。


六、那些让它"像样"的体验细节

到这它已经能用了。但"能用"和"好用"之间,隔着一堆琐碎但关键的体验:

上下文自动压缩。 对话一长,prompt token 迟早撑爆 context window。所以每轮收尾会检查累计 token,超过阈值(默认 12 万)就自动把更早的历史压缩成摘要,保留最近 4 轮原文。你几乎感觉不到,但它让长会话不会突然崩。

@ 文件引用。 在输入框直接打 @src/foo.js:L10-L25,REPL 会当场把那段代码展开成代码块塞进消息,省掉一次让模型先 read_file 的往返。小功能,但用顺手了就回不去。

剪贴板桥。 /paste 把系统剪贴板的内容当成"你选中的代码"附到下一条消息里,macOS / Linux / Windows 各自调原生 CLI(pbpaste / xclip / Get-Clipboard),同样零依赖。

会话持久化。 自动存到 ~/.mini-agent/sessions/,按项目目录恢复,关掉重开接着聊;/save <name> / /load <name> 还能存命名快照。

浏览器 Inspector。 这是我自己最得意的一个。开 INSPECTOR=1,在 Vue / React / Next 的 dev 页面上 Alt+Click 任意元素,弹个浮层写一句"这个按钮再大一点",mini-agent 就能收到结构化的 {file, line, computedCss, intent},直接定位到源码去改样式 ------ 告别"截图丢给 AI 然后描述半天它在第几行"。(顺带一提,这个功能我还踩了个 React 19 的坑:注入脚本里字面量的 $String.replace$' 特殊序列吞了,导致页面 SyntaxError,调了好久。所以说魔鬼真的都在细节里。)


七、写完这一圈,我到底学到了什么

回到开头那个问题:Coding Agent 是不是黑魔法?

不是。 它的骨架朴素得可能让你失望 ------ 一个带步数上限的 while 循环,加一套 OpenAI tool calling 协议,就这么点东西。你完全有能力在一个下午搞懂它的主干。

但它也确实不简单。 真正的工程量不在那个循环,而在你想让它"好用、安全、不出岔子"时冒出来的那一长串问题:SSE 怎么稳定分帧、tool_calls 怎么增量拼接、并发和顺序怎么权衡、模型输出怎么当成攻击面来防、Ctrl-C 在不同时刻怎么表现、上下文爆了怎么优雅降级......每一个拎出来都不难,但全部做对、还做得让人感觉不到,这才是 Claude Code 们真正值钱的地方。

对我个人来说,最大的收获是那种"祛魅"之后的踏实感 ------ 我现在用任何一个 Coding Agent,脑子里都能大致勾出它背后那个循环在转,知道它每一步在跟模型交换什么。这种掌控感,比单纯当个用户爽多了。

整个项目坚持零依赖、单文件可分发,源码我刻意写得很白,每个模块顶部都有中文注释讲"为什么这么写"而不是"做了什么"。如果你也想把 Coding Agent 这层窗户纸捅破,强烈建议你别只是读,自己撸一个 。哪怕只写到第一节那个 agentTurn 循环能跑通,那种"哦------原来如此"的瞬间,就值回票价了。

共勉。有问题评论区聊。

相关推荐
海鸥-w1 小时前
前端学习python第三天笔记整理(list 列表,str字符串,tuple元组,set集合,dect,函数,类型注解)
前端·python·学习
flavor1 小时前
Vue3 大屏适配组件(Scale / Rem 双方案一键切换)
前端
掰头战士1 小时前
搞定JavaScript类型判断,一文就够了
javascript
用户059540174461 小时前
把 AI Agent 记忆验证从手工比对换成 Pytest + 向量数据库,测试效率提升 10 倍
前端·css
要写代码1 小时前
2026-Css忘掉一切、归零再启-alpha和opacity
前端
光影少年1 小时前
前端如何和蓝牙物联网进行通信和兼容性问题
前端·物联网·掘金·金石计划
星栈1 小时前
我把售后模块砍到只剩 64 行:Rust 全栈 CRM 的 MVP 取舍实录
前端·后端·开源
玉宇夕落1 小时前
懒加载与Suspense的学习
前端
用户1733598075371 小时前
纯前端实现PDF合并、拆分、压缩:技术方案与踩坑记录
前端