用 3000 行 Go 实现 Coding Agent:内核与外壳的分层取舍
过去半年开发了两个项目:
两个项目最值得展开的并不是某一处工具实现或提示词设计,而是一个越往后越关键的架构决定:
把它拆成两层。
下层称为内核(kernel),承担最小、最稳定的执行循环。
上层称为外壳(harness),将内核放入真实工程环境,补齐会话、审批、压缩、计划、提醒等一系列长尾职责。
这一分层在概念上简单,但落到代码、接口契约、以及"内核不感知外壳"的纪律上,远比想象中难。本文记录的是这一拆分背后的取舍,以及它带来的代价。
核心论点
一句话:
Agent Loop 30 行就能跑通;能否长期跑得稳,取决于内核与外壳的接口划得是否干净。
几条具体判断:
- 一个 Agent 项目起步时可以混在一起写。但一旦它需要持久化、审批、上下文压缩、计划/提醒注入,混合的代码会迅速腐烂。
- 内核应当是无状态的自由函数,所有依赖通过参数注入。它不应感知 TUI、Web 还是 Slack。
- 外壳必须围绕"内核暴露的事件流和钩子"展开,而不是反过来要求内核为它开洞。
- 如果今天重做这套系统,第一天就会拆分。
一、Agent Loop 的最小骨架
先看最简骨架(概念示意,非任何具体框架的真实 API):
for {
msg := model.Generate(ctx, messages, tools)
messages = append(messages, msg)
calls := msg.ToolCalls()
if len(calls) == 0 {
return msg
}
for _, c := range calls {
result := tools[c.Name].Execute(ctx, c.Args)
messages = append(messages, toolResultMsg(c.ID, result))
}
}
它做四件事:
- 把上下文交给模型;
- 模型返回一段文本或一组工具调用;
- 程序执行工具调用;
- 结果写回上下文,下一轮继续。
跑通这套循环不难,但很快会暴露几类问题:
- 模型死循环,工具调用停不下来
- 工具失败后模型继续假装成功
- 输出 token 卡满,工具调用 JSON 截断为废话
- 上下文塞爆,下一次调用直接 400
- 用户中途想打断或修正,没有插入点
每一条问题,模型本身都修不了。它们的根源都在循环之外——上下文谁压、工具谁拦、用户怎么插话、什么时候该停。
二、为什么拆成两个仓库
项目第一版混在一起写:单个 Go 模块同时包含 agent loop、session 持久化、TUI 渲染和权限审批。
能跑,但任何一处变更都会牵动其他两处:
- 为 loop 增加上下文溢出自动压缩,发现压缩策略与 session 持久化耦合死了
- 想把 TUI 替换为 Web 后端,发现 loop 内部偷偷依赖了 TUI 的事件类型
- 想给 Plan 模式加一个"提交计划即退出循环"的钩子,发现没有可插入的位置
这类问题的本质,是两类抽象被一根线拴在一起:
- 一类是"和模型怎么对话"——loop、tool、event、stream,纯粹的执行问题
- 一类是"在工程系统里 Agent 怎么活"——session、approval、memory、reminder、UI
它们的演进节奏完全不同。
执行问题要稳。一旦定下流式契约、工具执行顺序、错误恢复语义,就最好不要动。
工程问题要快。审批模式扩档、reminder 触发条件调整、TUI 切换渲染——这类变更天天发生。
混在一起,等于让稳的部分被快的部分拖着改。
最终的处理是:执行部分抽离为 agentcore,工程部分留在 codebot,两者通过一组只暴露事件和钩子的接口对齐。
下面分别说明两层职责。
三、agentcore:内核只做四件事
agentcore 当前约 7500 行 Go,只做四件事。
1. 双循环
agent loop 不是单循环,而是双循环。
for { // 外层
for hasMoreToolCalls || len(pendingMessages) > 0 { // 内层
// 调 LLM、执行工具、写回上下文
}
// 内层退出后,给应用一次反悔机会
if followUp := config.GetFollowUpMessages(); len(followUp) > 0 {
pendingMessages = followUp
continue
}
if decision := config.StopGuard(ctx, StopInfo{...}); !decision.Allow {
pendingMessages = []AgentMessage{UserMsg(decision.InjectMessage)}
continue
}
break
}
为什么要双层?
内层只负责"持续推进":仍有工具待执行、仍有用户 steering 消息、上下文未空。
外层负责"真正停止",并为上层保留一个否决入口:StopGuard 可以否决停止,也可以注入一条 user 消息使循环继续。
codebot 的 Plan 模式正是基于此:plan mode 下用户提交计划 → 触发特定工具 → StopAfterTool 钩子让 loop 立即退出,不必等待模型自行结束。这一能力依赖外层循环,单层结构要么停得过死,要么停不下来。
2. 事件流:唯一出口
整个内核只有一个输出口:
func AgentLoop(
ctx context.Context,
prompts []AgentMessage,
agentCtx AgentContext,
config LoopConfig,
) <-chan Event
一个 <-chan Event。
TUI、JSON 输出、日志都消费同一条事件流。事件类型约十余种:
EventAgentStart EventTurnStart EventTurnEnd
EventMessageStart EventMessageUpdate EventMessageEnd
EventToolExecStart EventToolExecUpdate EventToolExecEnd
EventToolApprovalRequest EventToolApprovalResolved
EventRetry EventError EventAgentEnd
一个值得注意的设计:内核中有状态的 Agent 类型自身也是这条事件流的消费者,与外部 listener 共用同一路径,没有特权通道。
// agent.go: consumeLoop
for ev := range events {
// 自己更新内部状态
switch ev.Type { ... }
// 然后再分发给外部订阅者
for _, fn := range listeners { fn(ev) }
}
这一约束看似细节,意义却不小:内核没有为自身保留任何后门。Agent 自身作为消费者都能从事件流获取全部信息,外部任何 UI 同样可以。
3. 两段流水线
调 LLM 之前,agentcore 跑两段固定的变换:
AgentMessage[] (内部)
↓ Stage 1: ContextManager.Project / TransformContext ← 裁剪、注入、压缩
LLM-bound AgentMessage[]
↓ Stage 2: ConvertToLLM ← 过滤为 LLM 可见 Message
[]Message
↓ Strict ? AssertMessageSequence : RepairMessageSequence ← tool_call/result 配对
↓ 系统块前置(缓存友好)
↓ ReminderGens 后置(每轮变动放尾部)
↓ model.GenerateStream
第一段 Project 由上层决定"这一轮 LLM 看什么",压缩、投影、临时注入系统补丁都在此完成。
第二段 ConvertToLLM 将内部 message 类型转换为 provider 接受的格式。
这一分段带来一个关键副作用:前缀缓存友好。
- 系统块永远位于最前
- 历史消息按追加顺序生长
- 每轮变化的 reminder 放在最尾部
前缀字节稳定后,OpenAI 前缀缓存与 Anthropic block 级缓存均能命中。codebot 中 /btw、命令补全等附属调用通过 BuildLLMMessages() 复用主 agent 的消息前缀,调用成本相应下降。
这一设计需要在早期定型,后期补强成本极高。
4. 工具执行:把"边界"硬编码进协议
工具执行流水线如下:
EventToolExecStart
→ 熔断器检查(连续失败 N 次跳过)
→ JSON Schema 验证(一次性收集所有错误,自然语言输出)
→ Preview(如果工具实现了 Previewer)
→ 权限决策(PermissionChecker → PermissionEngine)
→ 注入 progress 回调
→ 中间件链 / Execute
EventToolExecEnd
每个 emit 点都对应外部 UI 所需的一个钩子。UI 友好被硬编码进了协议:消费方只需订阅事件流,即可渲染流式工具执行、审批弹窗、进度条。
一个值得注意的工程细节:JSON 验证一次性收集所有问题。
// validateToolArgs 同时检查 missing 和 type mismatch
// 自然语言一行一条,方便弱模型一次改对
InputValidationError: read_file failed due to the following 2 issues:
The required parameter `path` is missing
The parameter `offset` type is expected as `integer` but provided as `string`
经验上,相较"遇到第一个错误就报",弱模型在这种反馈下恢复明显更快——一次即可改对所有错,避免连续多轮试错。
四、codebot:harness 在外面补的五种边界
内核稳定后,剩余的工程问题都在外层处理。codebot 在 agentcore 之上补齐了五类边界,每一类都是 agentcore 不做、也不应该做的事。
1. 会话边界
agentcore 的 Agent 仅是内存对象,进程退出即丢失。
codebot 在外面加了一层 append-only 的 JSONL 持久化:每条 message、每次 tool call、每次 approval 决策都即时落盘。崩溃不丢失,重启可恢复,并支持从任意时间点 fork 出新分支。
~/.codebot/sessions/2026-05-04T19-32-45/session.jsonl
为何不在 agentcore 内部完成持久化?因为持久化策略与应用形态强相关:CLI 倾向 JSONL,Web 后端可能选择 Postgres,agentcore 不应绑定其中任何一种。
2. 审批边界
agentcore 提供了 permission.Engine 接口,但默认实现仅是个 stub:返回 allow 即结束。
codebot 在外层包了一层四档模式:
| 模式 | 行为 |
|---|---|
| strict | 任何工具调用都需询问 |
| balanced(默认) | 写操作 + 危险命令询问,读操作放行 |
| accept-edits | 文件编辑自动允许,命令仍需审批 |
| trust | 全开(仅供受控环境) |
辅以危险命令分类器(rm -rf、sudo、dd 直接拦截)、workspace 边界(read_roots / write_roots)和 JSON 审计日志。
一个值得展开的设计:agentcore 权限决策的返回值包含 UpdatedArgs 字段,决策可以改写参数。由此 TUI 弹窗能支持"先编辑参数再批准"的交互。一个小钩子换来一类质变能力。
3. 上下文压缩边界
agentcore 提供 ContextManager 接口,定义了 Project / Sync / RecoverOverflow 三个方法,具体实现策略不内置。
codebot 实现了几种:
- session_memory:保留关键 message 摘要
- summary:超过阈值后让模型对早期对话做总结
- trim:丢弃过期 reminder 和大尺寸 tool result
切换策略只需替换一个 ContextManager 实现。
更进一步的是 agentcore 的溢出恢复机制:
// loop.go: callLLMWithRetry
if IsContextOverflow(err) {
return recoverOverflow(ctx, agentCtx, config, ch, err, hooks, turn)
}
API 报告上下文超长 → 自动调用 RecoverOverflow 压缩一次 → 以压缩后的视图重试 LLM 调用。整个过程对 codebot 透明。
若当初让内核内置某一种压缩策略,今天扩展新策略将需要同时改动两层。
4. Plan 边界
Plan 模式的语义是:先由 Agent 产出计划,经用户审查批准后才执行。
如何落到 loop 中?agentcore 提供了一个钩子:
config.StopAfterTool = func(name string) bool {
return name == "submit_plan"
}
submit_plan 一旦成功执行,loop 立即退出,不再向模型询问后续动作。codebot 接到事件后弹出审批 UI,用户批准 → 退出 plan mode → 重新 Prompt 让 Agent 进入实际执行阶段。
整个 Plan 模式在 codebot 中是一套完整子系统(internal/plan/);在 agentcore 中并不存在——agentcore 不知道 Plan 是什么。这正是分层的收益。
5. Reminder 边界
每一轮调 LLM,是否要在末尾贴一段提醒?
- plan mode 下,需要提醒模型不要直接修改文件
- 上下文压缩发生后,需要告知模型早期对话已被精简
- TaskList 中存在未完成任务,需要列出
agentcore 提供 ReminderGens []ReminderGenerator,每轮生成 0..N 条提醒,追加在消息末尾,不打断前缀缓存。
codebot 在 internal/agent/plan_reminders.go、task_reminders.go 中实现具体生成逻辑。内核提供契约,外壳填充内容。
五、接口设计:内核绝不知道外壳的存在
这一节将分层背后的纪律明确化。
agentcore 仓库中检索不到以下任何字符串:
session
approval
plan
compaction
TUI
JSONL
它仅认知以下抽象:Tool、Message、Event、PermissionEngine、ContextManager、ReminderGenerator、StopGuard。
这一纪律意味着:为 codebot 增加新的 harness 能力时,应当首先自问:
「现有的 agentcore 接口是否够用?如果不够,要补的钩子是否真正通用?」
而不是:
「能不能在 agentcore 里偷偷加一个 codebot 专用字段?」
后者短期省事,长期会把分层吃掉。
若确实需要给 agentcore 增加新钩子,可按以下顺序判断:
- 该能力是否只服务 codebot?是 → 加在 codebot 一侧
- 是否任何 Agent 系统都可能用到?是 → 加在 agentcore 接口中,并提供默认实现
- 介于两者之间?→ 增加一个可选接口(Go 的 type assertion),实现则用、未实现则 fallback
agentcore 当前已有十余个此类可选接口:Previewer、PermissionChecker、ContentTool、ContextLLMConverter、ContextEstimator、StrictSchemaTool、DeferFilter……均按上述判断逐步演化形成。
六、回头看:分层换来了什么、付出了什么
收益
agentcore 可独立测试与发版。目前由 codebot 使用,但任何 Go Agent 项目都可以引入,与 codebot 不存在强绑定。
codebot 的迭代速度大幅提升。权限模式从两档扩到四档、新增 plan mode、调整 TUI——只要不触碰 agentcore 接口,下层完全无感知。这类变更可以在一周内完成。
能讲清楚每个能力的归属。新人加入时常问"context 压缩在哪实现",回答只需一句:"internal/agent/session_compaction.go 实现 agentcore.ContextManager 接口。"两层各自一句话即可交代清楚。
代价
调试需要跨仓库。栈跟踪在 codebot 与 agentcore 之间来回切换。本地通过 go.work 可同时改动两侧,但远程协作者未必采用同样配置。
接口设计的负担前置。每一个内核接口的名字、参数、返回值、可选接口,一旦发布出去就要保持兼容。这要求做之前想清楚。
过度抽象的诱惑。更大的风险并非分层不足,而是反向滥用:为了"内核纯净"把非通用能力也塞进去。agentcore README 里写下了这条纪律:
No premature abstraction —— 每个接口必须有 ≥ 2 个真实调用方。
只有一个调用方的接口,宁可放在外壳里。
写在最后
一个判断:
外壳决定 Agent 能否长期运行;而外壳之所以能稳定运行,前提是下层内核被当作独立工程对象认真维护——它有自己的接口契约、版本号、测试集与发版节奏。
若仅把外壳视为"包在 loop 外面的一层逻辑",它迟早会与某个具体的 loop 实现长在一起,再无解耦的可能。