本文整理自 CNB 上的开源课程 《Vibe Agent:从零搭建 Coding Agent》。课程讲义、示例代码与练习仓库均在 CNB 上公开,文中示例出自该仓库对应章节。

前面几章已经把零件焊完了:一个能对话的 LLMClient,一个能注册工具的 ToolRegistry,一个带循环的 Agent,一套上下文与记忆组件,外加五个编程工具。这一节不引入新功能,只处理两个工程问题:这些零件按什么顺序拼装,拼好之后能做成什么形态。

手上的零件

来源 组件 主要方法 职责
第一章 LLMClient chat、count_tokens 调模型、数 token
第二章 ToolRegistry register、to_schemas、invoke 注册工具、生成 schema、按名分发
第三章 Agent run(user_message) 持有循环,调模型、执行工具、回灌结果
第四章 ContextManager should_compress、compress 历史过长时压缩 messages
第四章 Memory add、search 存长期事实、按语义召回
第五章 编程工具 read、write、edit、list_dir、bash 读写文件、执行命令

自定义工具用 register(func, description),第二个参数是写给模型看的说明;第 5 章的五个编程工具由 build_registry(workdir=...) 一次装配,注册时顺带把工作区注入进去。

组件之间有两种关系,混在一起看容易糊涂。一类是结构上的持有关系:Agent 构造时注入 LLMClient 与 ToolRegistry,这两个缺一不可;ContextManager 与 Memory 是可选的,不注入也能跑。另一类是运行时的协作次序:一次 run 开始时先召回记忆、取工具清单,然后进入循环,每轮判断是否需要压缩,再调模型,有工具调用就执行并回灌,直到模型不再要求调用为止。

所有组件都围绕一个数据结构转:messages。记忆拼进它,压缩裁剪它,模型读它产出结果,工具结果又写回它。理解这一点,后面的组装步骤就只是摆放位置的问题。

先让它跑起来

组装不必一步到位。工程上常用的节奏是:先能跑,再扛得住,然后才谈好看。第一阶段只需要三个组件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from llm import LLMClient
from agent import Agent
from coding_tools import build_registry

SYSTEM_PROMPT = """你是一个编程助手,运行在用户的项目目录里。
需要信息时先用工具查证,不要凭假设编造文件内容。
改代码前先读相关文件,改动保持最小。
破坏性操作先征得同意。"""

llm = LLMClient()
# build_registry 一次注册 read / write / edit / list_dir / bash,
# 并把 workdir 注入工具:相对路径解析到工作区,绝对路径越界会被拒绝
tools = build_registry(workdir="/path/to/project")

agent = Agent(llm, tools, system_prompt=SYSTEM_PROMPT)
print(agent.run("把 utils.py 里的 add 改成支持任意个参数"))

这套代码已经是可用的 Coding Agent。它会自己读文件、改文件,再返回一句总结。循环、工具分发、消息回灌都封装在 Agent 内部,调用方拿到的契约只有一条:传进一句话,返回一个字符串。

再让它能连续对话

真实使用很少一次结束,通常是一来一回。把 run 放进循环就是 REPL:

1
2
3
4
5
6
7
def run_repl():
agent = build_agent()
while True:
user_input = input("> ").strip()
if not user_input:
continue
print(agent.run(user_input))

这里有个坑值得单独说:agent 实例必须在循环外创建。多轮对话的历史保存在实例内部,每轮新建一个实例,第二句话时它就不知道第一句聊了什么。很多”Agent 失忆”的报障,根源是把构建逻辑写进了循环。

循环能用了,还差三件小事才算合格。第一是异常处理,用 try/except 捕获 EOFError 与 KeyboardInterrupt,否则 Ctrl+C 会把整个进程带走。第二是退出与重置,/quit 结束进程,/reset 丢掉历史重建实例。第三是过程可见,run 只返回最终字符串,中间调了什么工具用户完全看不到,需要在 Agent 的循环里打印工具名、参数和截断后的结果。

第三件事看似是体验问题,实际影响信任。用户看不到 read 了哪个文件、bash 跑了什么命令,就无法判断结论是否可靠。

外挂式增强

对话变长、需要跨会话记住事实时,再把第四章的两个组件接进来。它们不修改 Agent 内核,只在调用前后加一层。

1
2
3
4
5
6
7
8
def run_with_memory(agent, user_input):
facts = memory.search(user_input, k=3)
if facts:
joined = "\n".join(f"- {text}" for _, text in facts)
agent.system_prompt = BASE_PROMPT + f"\n\n已知用户事实:\n{joined}"
answer = agent.run(user_input)
memory.add(user_input)
return answer

ContextManager 的接入点在内核循环的开头。每轮先问一次是否超长,超长就压缩,再交给模型。两个组件的共同点是都可以缺席:没有记忆,Agent 只是每次重新认识用户;没有压缩,长对话迟早撞窗口。缺席不会立刻报错,所以更容易被忽略。

剩下的是显示层。把 print 换成 rich 的面板与语法高亮,内核一行都不用改。界面美化与逻辑增强在这里彻底分开,这也是判断一个 Agent 代码结构是否健康的小测试:如果换个终端样式需要动循环,说明耦合写错了地方。

一个内核,三种壳

内核组装好之后,它面向哪些场景使用,取决于外面套哪层壳。

REPL 是多轮交互形态,代表是编辑器侧边栏与终端里的编程助手。Solo 是一次性任务形态,给一个任务、跑完退出,适合 CI 脚本和定时任务,比如每天自动审 PR、流水线失败时生成分析报告。WebUI 是浏览器形态,适合查看 diff、贴截图、做演示。

Solo 的实现只是不进循环:

1
2
3
4
5
6
7
8
9
def run_solo(query, output_file=None):
agent = build_agent()
answer = agent.run(query)
if output_file == "-":
sys.stdout.write(answer)
elif output_file:
open(output_file, "w", encoding="utf-8").write(answer)
else:
print(answer)

有一个细节容易被忽略:Solo 模式下,工具调用、调试日志这类过程信息应该走 stderr,最终结果走 stdout。这样用户用 > 重定向或接管道时,拿到的文件里只有答案,不会混入中间过程。这条约定看起来不起眼,却是 Agent 能被脚本组合的前提。

三种形态共用同一个 build_agent 与同一个 run,main 函数里分流即可。形态是壳,内核只有一套。

组装顺序本身就是设计

回头看这五个步骤,顺序不是随意的。三件套先跑通,说明最小闭环成立;REPL 暴露上下文归属问题;打磨阶段暴露可观测性问题;接入记忆与压缩时,才需要决定哪些能力是可选的;最外一层只换显示。

每一次都是在上一版能跑的前提下加东西,任何一步出问题都能立刻定位到刚加的那层。这和”先把所有组件都接上再调试”是两种完全不同的排错体验。

下一节讨论两个部署层面的问题:业务同事写的规范怎么在不改代码的前提下装进 Agent,以及怎么把这个 Agent 挂进代码仓库,让它在有人提 issue 时自动接手。