先说结论
这阵子大家都在用 Claude Code、Cursor、Codex,体验是真香,但用着用着我心里总有个疙瘩:
这玩意儿到底是不是黑魔法?
它怎么知道该去读哪个文件?它改我代码之前那个 diff 是怎么来的?我按 Ctrl-C 它怎么就能稳稳地停下来、还不把对话搞乱?流式输出那一个字一个字往外蹦的效果,背后到底发生了什么?
为了把这些问号一个个抠掉,我干脆自己撸了一个 ------ 一个零运行时依赖、克隆下来就能跑的命令行 Coding Agent。约束我给得很死:
package.json里dependencies和devDependencies必须是空的,所有功能只能用 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。这东西有两个反直觉的点:
- 它能要 5~30 秒才出第一个正文字符。 如果这期间你屏幕上啥都不显示,光转个 spinner,用户百分百以为卡死了。所以我把思维链用灰色暗调实时打出来,让用户看到"它在想"。
- 它必须随 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这类)→ 直接拒绝,连问都不问; - 白名单 (
ls、npm test、pytest这类只读或安全命令)→ 免确认直接跑,但仍然打一行日志,绝不背着你偷偷执行; - 灰名单 (其余的)→ 弹确认,你按
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 而不是 exec(exec 有 maxBuffer 限制,长输出会被截断甚至 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 循环能跑通,那种"哦------原来如此"的瞬间,就值回票价了。
共勉。有问题评论区聊。