自己实现一个 coding agent 学到的
2026-02-21
ai;dr
对于 vibe coding,之前我一直是使用者。用过很多的工具,试过很多的模型。从使用中,我能感知到一些差异。模型的差异,工具的差异。体验上最强的,还是 codex+gpt5.2,或者 claude+opus 这种。
如果说模型是核心的话,工程打磨几乎是同样的影响体感的。比如我在使用 claude code 和使用 opencode,调用同样的 GLM 模型,使用的感受上也都不一样。
claude 明显打磨得更好一些,而 opencode 那边,就有遇到过处理文件的过程中乱码了,然后开始不停地死循环在屏幕乱刷的。 再比如,当我用 claude code 的时候我发现它的 token 消耗速率明显要比有些工具更快,这不是贬义,这背后是它用 agent 并行的方式,加快了任务完成速度。
所以,coding agent 这块,可以粗暴地说,70% 是模型能力,20% 是工程打磨,10% 是 prompt 写得好不好。
为什么自己造轮子
其实在实现自己的 agent 之前,我已经做了很长时间的"缝合"工作了。
最初是给 ad —— 我自己日常使用的编辑器 —— 做一个 bridge 插件,让它能对接 pi 这个 agent。 pi 的设计挺极简的,就是一个 agent loop,然后加一些工具调用。核心代码很少,很适合拿来做二次开发。
后来想着,反正都看了这么多 agent 的源码了,不如自己搞一个?
于是就有了现在这个叫 ai 的工具。第一版总共 4000 多行代码,通过 vibe coding 半天写完的,然后陆陆续续打磨了一二个星期。
工程细节
说实话,agent 的核心逻辑真的很小。就是一个 loop:调用 LLM,拿到 response,解析出 tool call,执行工具,把结果 append 到 context,再调用 LLM。就这么简单。
但魔鬼在细节里。
LLM 返回格式错误
最坑的是 LLM 返回的格式不一定是对的。
比如你让它返回 JSON,它可能给你返回一个 markdown 代码块包着的 JSON,甚至有时候会直接给你返回 XML 格式的标签。这些都是真实遇到过的。
所以容错处理很重要。我的做法是,先尝试按预期格式解析,失败了再尝试各种 fallback:去掉 markdown 代码块标记、尝试解析 XML、甚至正则表达式硬抠。
buffer 的边界问题
工具的输出可能是很大的,比如 grep 一个大项目的结果。直接塞进 context 会爆。
所以需要把 buffer 切成 chunk。但这里有个坑:utf-8 字符可能被截断。如果恰好切在一个多字节字符的中间,后面解析就炸了。
解决方法是在切 chunk 的时候,往回找到最近的 utf-8 字符边界再切。Go 里面 utf8.DecodeLastRune 可以搞定这个。
死循环
agent 有时候会陷入死循环。比如一个工具调用失败,它 retry,又失败,又 retry...就这么一直循环下去。
一开始我以为是因为模型不够聪明,不知道及时止损。后来发现,有时候是因为 context 里面的信息太多了,模型"忘记"了自己之前已经尝试过这个方案。
还有一个更隐蔽的原因:工具的返回信息里,包含了错误信息。模型看到错误信息后,试图"修复"问题,但修复的方式又触发了同样的错误。
我的解决方案是加一个简单的检测:如果连续 N 轮的 tool call 都是同一个工具并且参数几乎一样,就强制打断,让模型换个思路。
tracing、log、metrics
这三个东西在一开始就要设计好。不然等到 debug 的时候,你会发现根本不知道 agent 在干嘛。
我的做法是,每一个 tool call 都记录下来,包括入参、返回值、耗时。session 的消息也全部会写到一个 jsonl 文件里。这样出了问题可以回放整个执行过程。
context engineering
context 是 agent 的"工作记忆"。太短了,模型记不住东西;太长了,成本爆炸,而且模型也会变笨。
compaction
当 context 越来越长的时候,需要做 compaction。常见的做法有几种:
- 滑动窗口:只保留最近的 N 条消息,旧的直接扔掉。最简单,但也最暴力。
- summary:用另一个 LLM 调用,把旧的消息压缩成一段摘要。效果好一些,但多一次 LLM 调用,有信息损失。
- 永久记忆:把重要信息存到外部存储里,需要的时候再检索出来。最 fancy,但也最复杂。
我自己用的是滑动窗口 + summary 的组合。工具的消息会单独做 summary,用户的消息和 agent 的响应尽量保留。
还有一个优化点是,summary 不一定要等到需要 compact 的时候才做。可以后台起一个 goroutine,持续地把"已经不需要详细参考"的工具消息压缩掉。这样等到真正要 compact 的时候,工作量就小很多。
plan mode
plan mode 是现在已经被熟知的一个概念。大意是,对于复杂任务,先让模型出一个计划,然后按计划一步步执行。
这样做的好处是,模型的注意力不会"漂移"。不然执行着执行着,可能就跑偏了,开始处理一些边缘问题,把主线任务忘了。
我自己没有做专门的 plan mode。一个比较简单的 prompt 方式是,把 plan 记录到文件(比如 plan.md),然后让 LLM 每一轮完成后,更新 plan。
这样每次的更新过程就需要重新读取 plan.md,于是间接地将 plan 一直保存在上下文中。
fork 和 resume
这是 pi 里面提到的一个机制。场景是这样的:
agent 执行到某个阶段,比如 phase3,突然遇到一个报错需要修复。如果直接在当前 context 里修复,修完之后 context 就被"污染"了,混入了不相关的内容。
pi 的做法是,fork 一个新的 session,在新的 session 里修复问题。修完之后,回到原始 session,继续往下走。这样原始 session 的 context 还是干净的。
这个机制我也借鉴过来了。
subagent
跟 pi 一样,subagent 在这边没有用特殊的处理,就直接加了一个 headless 模式:只一行 prompt 然后收集结果。
起单独的进程运行 headless 模式,这个进程就是一个 subagent 了。
我没有把 subagent 作为 tool,而是直接以 skill 方式提供。用的时候就像调用一个函数一样,传入任务描述,拿到返回结果。
skill 系统
除了 subagent,我还做了一个 skill 系统。每个 skill 就是一个 markdown 文件,描述了某个任务应该怎么执行。
比如 go-testing 这个 skill,里面写了 Go 单元测试的最佳实践:table-driven 测试、error message 格式、mock 的使用等等。
当用户让 agent 写测试的时候,agent 会先读取这个 skill 文件,然后按照里面的指导来写。
这样做的好处是,skill 可以动态加载,不需要改 agent 的代码。用户也可以自己写 skill 来扩展 agent 的能力。
结语
后面的打磨花了我不少时间。我现在可以在 ad 里面,日常使用自己实现的这个 coding agent 了。
但是要客观评价的话,当前 claude code 和 codex 应该是第一梯队的。 然后部分闭源产品,以及头部的开源 agent 是第二梯队,比如 opencode/aider/goose 这类的。 第三梯队就是其它一些开源 agent,像 pi 这种极简 agent,还有我自己这个,都属于第三梯队。
debug 的时候我好几次差点快要放弃了。如果不因为沉没成本,老老实实对接 claude 或者 codex 其实是性价比最佳的。
我意识到,无论我自己怎么优化和打磨,也达不到第一梯队水平。第一梯队的背后,是专门的团队在持续投入,有用户反馈数据可以迭代,甚至是模型和工具之间的定制化的优化,这些都是个人项目没法比的。
幸好由于 AI 写代码能力本身很强大,我"借鉴"开源实现很方便。现在的实现最初是参考 pi 的,最后完全变成了一个缝合怪 —— 遇到问题了,就去看看其它 agent 怎么做的,然后抄过来。
最后的结果嘛,至少是能满足我自己的使用需求了。只要一直在用,一直在持续抄,就可以至少达到开源实现的头部水平。
用起来还是没有 codex / claude 好用。也许最大的收获,就是实现一个 coding agent 能让我对 agent 的理解更深刻,从而能更好地使用它。
就像造过轮子的人,才知道轮子为什么是圆的。