Coding Agent 架构设计:从 Agent Loop 到插件系统,拆解一个 AI 编程助手的核心模块
Coding Agent 架构设计:从 Agent Loop 到插件系统,拆解一个 AI 编程助手的核心模块
1. 概述
Coding Agent 的本质不复杂–一个在"问模型"和"执行工具"之间循环的 while 循环。但把这个循环变成每天能用的开发工具,需要一层层加上会话管理、上下文压缩、资源加载、插件系统等模块。
现代 Coding Agent 的架构核心思想是三层分离:模型适配层(屏蔽不同 LLM 的差异)、内核层(Agent Loop 运行时)、产品层(会话/压缩/资源等循环之外的事)。各层独立、松耦合,通过接口或协议通信,可以独立替换实现。
理解了 Coding Agent 的构建原理,就掌握了理解其他业务 Agent 的一把钥匙–客服、数据分析、工作流编排等业务 Agent,追根溯源都是 Coding Agent 的泛化变种。
2. Agent Loop:一切的核心
Agent Loop 是什么?一个 while 循环,在"问模型"和"执行工具"之间来回切换,直到模型给出最终答案。它不关心模型是 OpenAI 还是 Anthropic,不关心工具是读文件还是跑命令,只关心两件事:模型要不要调工具?如果要,调完继续问;如果不要,结束。
核心逻辑(简化后):
while (turn < max_turns) {
// 1. 调模型
assistant = model.complete(messages, tools)
// 2. 检查是否有工具调用
if (assistant.toolCalls().len == 0) break
// 3. 逐个执行工具,结果 append 回 messages
for (tool_call in assistant.toolCalls()) {
result = tool_registry.execute(tool_call.name, tool_call.args)
messages.append(result)
}
}
三个关键设计点:
- max_turns:防止模型陷入无限工具循环的安全阀。模型可能反复调用工具却始终不给出最终答案,上限是必要的。
- 工具结果 append 回 messages:模型需要看到工具执行的结果才能决定下一步做什么。如果不把结果回写,模型就像蒙着眼睛干活。
- 工具定义传进 complete():模型需要提前知道有哪些工具可用、每个工具的参数是什么,才能决定是否调用。
Agent Loop 还需要处理错误重试。LLM 调用和工具调用都有失败的可能,需要区分错误类型:可重试的错误(网络超时、临时限流)自动重试,不可重试的错误(参数校验失败、权限不足)直接抛出。
Agent Loop 只关心"循环",不关心消息从哪里来、执行结果存到哪里。这些循环之外的事,由产品层封装。
3. 模型适配层:统一不同 LLM Provider 的差异
模型适配层的职责只有一句话:把不同 LLM Provider 的 API 差异封装在一个统一接口后面。Agent Loop 只认这个接口,不关心背后是 OpenAI 还是 Anthropic。
统一接口需要处理的关键差异:
| 差异点 | OpenAI | Anthropic |
|---|---|---|
| 请求体格式 | messages[] 数组 | content[] 数组 |
| 工具调用结构 | tool_calls[] 独立字段 | content[] 中的 tool_use block |
| 流式协议 | data: 行 | event: 行 |
适配层把这些差异统一成 Message、Tool、AssistantMessageEvent 等内部数据结构。上层 Agent Loop 不需要知道"这是 Anthropic 的 tool_use 还是 OpenAI 的 function call",只关心统一后的 toolCall 内容块。
LLM 生成一个回答可能需要几秒甚至十几秒,如果等全部生成完再返回,用户只能干等。解决方案是流式输出(SSE):模型适配器在收到 SSE 的每个 chunk 时,调用 stream_callback 回调,Agent Loop 收到回调后立即通过 EventBus 发射事件,Client 收到事件后实时追加到终端显示。
4. 工具系统:Agent 的手和脚
模型适配层让 Agent Loop 可以调任何模型,工具系统让 Agent Loop 可以做任何事。
工具的定义包含四个要素:name(工具名)、description(描述,告诉模型这个工具能做什么)、parameters(JSON Schema,描述参数结构)、execute(执行函数)。
工具注册表是一个 HashMap,按工具名存储,支持三个核心操作:register(注册新工具)、definitions(返回所有工具定义,发送给模型)、execute(按名称查找并执行)。模型在每次调用 complete() 时,注册表会把所有可用工具的 JSON Schema 编码后传给模型,模型根据描述决定是否调用某个工具。
内置工具通常包括:read(读文件)、write(写文件)、edit(精确修改)、bash(执行命令)、grep(搜索)、glob(文件匹配)。扩展工具通过插件系统动态注册。
5. 产品层:循环之外的事
写一个 Agent Loop 不难,难的是把它变成每天能用的开发工具。产品层负责这些"麻烦但关键"的事情:会话管理、上下文压缩、资源加载。
5.1 会话管理(Session)
没有 Session,Agent 每次对话都是"失忆"的。存储格式采用 JSONL,每行一个独立 JSON 对象。第一行是会话头(id、创建时间、工作目录),后续每行是一条消息。
{"id":"sess_001","created_at":1717234567,"cwd":"/project","model":"gpt-4o"}
{"id":1,"parent_id":null,"timestamp":1,"role":"user","content":"帮我读 README.md"}
{"id":2,"parent_id":1,"timestamp":2,"role":"assistant","content":"我来帮你读..."}
每条消息带 parent_id,构成树状对话结构,支持分支 fork 和回滚–走错方向时可以回到之前的节点重新开一条分支。
会话恢复时逐行解析,损坏的行跳过不崩溃。这是工程上的健壮性设计–JSONL 文件可能因为进程崩溃而出现半行写入,解析时遇到损坏行应该跳过而不是整体失败。
5.2 上下文压缩(Compaction)
LLM 有上下文窗口限制,一个 Coding Agent 的对话可能持续几十轮,累积数千 Token。如果不做处理,早期消息会被窗口截断,模型"忘记"了之前的上下文。
压缩策略:当 Token 超过阈值时,把旧消息压缩成一条摘要,保留最近 N 条消息不变。
压缩前: [消息1] [消息2] [消息3] ... [消息N-10] [消息N-9] ... [消息N]
压缩后: [摘要: 之前讨论的要点] [消息N-9] [消息N-8] ... [消息N]
核心设计要点:
- Token 预算:不引入精确 tokenizer,用字符数除以 4 近似估算。压缩决策不需要精确到个位数 Token,够用就行。
- 触发条件:估算的消息 Token 总量超过配置的 max_tokens 时触发。
- 摘要生成:把旧消息拼接成文本,调用模型生成摘要,替换掉原始消息。
- 重试循环:压缩后重新执行 Agent Loop,如果仍然超限,继续压缩。
默认阈值 100K Token,保留最近 10 条消息,摘要目标长度 500 Token。
5.3 资源加载(Resources)
Resources 从文件系统加载项目规则和技能,格式化后注入 system prompt,让模型知道它有哪些工具和能力可用。
三类资源的加载顺序和优先级:
- 项目规则(优先级从高到低):当前目录的 AGENTS.md -> 当前目录的 CLAUDE.md -> 全局配置目录的 AGENTS.md -> 全局配置目录的 CLAUDE.md
- 技能(项目优先,同名冲突时项目级覆盖全局级):当前目录的 .agent/skills/ -> 当前目录的 .agents/skills/ -> 全局配置目录的 skills/
- 数据结构:每个 Skill 包含 name、description、filePath、source(global 或 project)、content
SKILL.md 文件携带 YAML frontmatter,解析后构建成 XML 结构注入到系统提示词中,告诉模型当前有哪些技能可用:
<available_skills>
<skill>
<name>basedpyright</name>
<description>Python static type checking</description>
<location>/path/to/skill/SKILL.md</location>
</skill>
</available_skills>
模型看到技能列表后,如果判断当前任务需要某个技能,就会主动读取该 SKILL.md 的完整内容。这就是渐进式披露的实践–初始只给目录,用到时再加载详情。
6. 事件系统与插件机制
Agent Loop 在跑,但外界怎么知道它跑到了哪一步?事件系统就是答案。Agent Loop 每做一件事–开始一轮、生成一个 Token、调一个工具–就往 EventBus 上发一个事件。谁关心这个事件,谁就注册回调。
EventBus 有三个回调槽(agent / session / compaction),插件安装时把原回调保存下来,换成自己的 dispatch 包装函数。执行时先执行原回调(写 socket 流式返回给客户端),再遍历所有已注册的插件,逐个调用对应的 hook 函数。
插件支持的 Hook 类型:
| Hook | 触发时机 | 可做操作 |
|---|---|---|
| on_tool_start | 执行工具前 | 拦截危险命令、修改参数 |
| on_tool_end | 工具执行后 | 修改执行结果、标记错误已处理 |
| on_context | LLM 调用前 | 注入系统指令 |
| on_agent_start | Agent 启动时 | 阻止启动 |
| on_session_before_compact | 手动压缩前 | 阻止压缩 |
插件的优势在于即插即用、动态加载。一个 bash-guard 插件可以在 on_tool_start 时拦截 rm -rf 这样的危险命令,在 on_context 时注入"使用 bash 时注意安全"的系统指令,不需要修改核心代码。
7. 网络层与客户端通信
网络层定义了 Server 与 Client 之间如何通信。核心设计:TCP + JSON line 协议,流式通信 + 全双工。
TCP 天生适合这个场景–客户端发一条消息,服务端把思考过程、工具调用、最终回答一条条推送给客户端,全双工意味着客户端可以随时发消息(比如中断请求),服务端也可以随时推送事件。
语言无关是网络层的核心价值。Server 不关心 Client 用什么语言实现,只要遵循 JSON line 协议就行。这意味着引擎层可以用追求性能的语言(如 Zig)实现,客户端可以用生态丰富的语言(如 Python)实现终端交互 UI,各取所长。
8. 总结
Coding Agent 的核心架构可以归纳为六个模块:
- Agent Loop:核心循环,在"问模型"和"执行工具"之间切换
- 模型适配层:统一不同 LLM Provider 的 API 差异
- 工具系统:注册表管理工具,支持动态扩展
- 产品层:会话管理(JSONL + 树结构)、上下文压缩(摘要 + 重试)、资源加载(规则 + 技能)
- 事件系统:EventBus 驱动,支持插件动态加载
- 网络层:TCP + JSON line,语言无关
各层之间通过接口或协议解耦,可以独立替换实现。理解了这套架构模式,就掌握了理解其他业务 Agent 的一把钥匙。
更多推荐


所有评论(0)