同步更新至个人站点:从 AI SDK 到 mini-opencode:一次很巧的 Go Agent 架构实践

相关链接:
从零构建 Mini Claude Code:stack.mcell.top/blog/2026/a...
本次 Mini OpenCode 仓库地址:github.com/minorcell/m...
Memo Code:memo.mcell.top/
前阵子,我写过一篇 从零构建 Mini Claude Code 的 Agent 开发入门教程。
那次基本是顺着 AI SDK 往下做的。不得不说,TypeScript 这套生态在 Agent 开发这件事上,确实已经非常成熟了。
很多原本你以为要自己接的东西,比如 llm provider 协议、message schema、tool calling、loop、streaming,这些基础设施基本都已经有人替你铺好了。
所以整体开发体验会非常顺。你不太需要先去想"这套系统最底层应该怎么搭",很多时候先把产品做出来就行。
后来我在看 opencode 早期实现的时候,注意到一个挺有意思的细节:
它早期居然是 Go 写的。
这件事一下子把我勾住了。
不是因为我突然想讨论"Go 和 TS 到底谁更适合 Agent",而是这个点刚好落在我自己的日常技术语境里。
因为我平时本来就在 TS 和 Go 两边来回切,所以这对我来说,不是一个"换门语言试试看"的问题,而是一个很自然的延伸:
既然前面我已经沿着 TS 那条路做过一版 mini claude code,现在又刚好看到 opencode 早期和 Go 有过这层关联,那不如顺着自己熟悉的另一条技术路径,再做一个 mini-opencode 看看。
于是,后面就有了这个小实验。
但这次真正有意思的地方,还不只是"换了门语言"。
更准确地说,是这次我把它当成了一次很完整的产品设计 + 架构设计实践。
这段时间我一直在外面长期出差,在和一群大学生协作(当然更多是观察)。
一开始,大家通常都是从产品问题进入的:要做什么功能、支持什么能力、界面怎么组织、用户怎么用起来更顺。这个入口很自然,因为产品首先总是以功能和交互的形式被感知到。
但一旦真正开始往下做,问题很快就会往更底层走。你会发现,同样一个功能能不能做稳、后面还能不能继续长,往往取决于另外一些东西:
- 模块怎么拆
- 接口怎么定
- 状态怎么流动
- 哪一层应该知道什么
- 哪一层不应该知道什么
- 边界应该放在哪里
也就是说,产品问题并不会消失,而是会继续往下传导,最后落到架构设计上。
而 mini-opencode 这次,恰好就是一个非常适合拿来做这件事的载体。
因为 Agent 这种系统表面上看是在"调模型",但你真正做进去以后,会很快发现它其实非常适合拿来练架构基本功。
这个东西应该怎么长
现在回头看,这次 mini-opencode 和之前那篇 mini claude code 最大的差别,不只是使用的编程语言。
而是入口变了。
上一次,我们更像是在一个非常成熟的应用层生态里往前走。很多问题你不用太早面对,因为框架已经替你做了选择。你沿着那条路,自然会先关注产品形态、交互、工具设计这些更靠上的问题。
但这一次换到 Go 以后,很多东西一下子变得更裸露了。
不是说做不了,恰恰相反,是很多结构性问题必须尽早想清楚:
- provider 怎么统一
- session 放哪
- loop 怎么闭环
- tool call 怎么表达
- 工具执行后的结果怎么回流
- UI 怎么知道系统当前在干什么
- 安全边界到底应该落在哪一层
你会发现,这时候讨论已经不再是"这个功能要不要加"。
而是一个更基础的问题:
这个系统应该怎么长,后面才不会乱?
这其实就是典型的架构题。
所以这次我一开始就没有从某个功能点切进去,而是先把项目拆成了 6 个模块:
cmdconfigprovidercoretoolstui
这个拆法表面上看很普通,但我后来越来越觉得,一个好的架构拆分本来就不应该追求"聪明",而应该追求两件事:
- 归属清楚
- 演进自然
config 负责配置加载、默认值、系统提示词、工作区解析; provider 负责统一模型接口和多厂商适配; core 负责 agent loop、session、turn 执行; tools 负责工具注册、工具执行和运行时拦截; tui 负责终端交互; cmd 负责把这些模块装配起来。
这套结构最直接的好处,不是什么"优雅",而是你一旦开始写代码,立刻知道:
- 新能力应该长在哪
- 变化应该落在哪层
- 某个问题出现时应该先去看哪块
这对一个要持续迭代的系统来说,非常重要。
因为很多项目不是死在"做不出来",而是死在"第二轮开始就不知道该怎么继续长,逐而陷入重构轮回甚至停止维护"。
好的模块拆分是为了让每一层只处理自己该处理的问题
这是我最近观察下来越来越确定的一件事情。
很多人一开始拆模块,容易变成"按文件类型拆"或者"按功能名拆",最后得到的是一组名字看起来合理、但职责边界非常模糊的目录。短期能跑,长期一定会相互污染。
mini-opencode 这次我反而特别在意一件事:
每一层到底在回答什么问题?
比如 provider 这一层,回答的是:
- 模型如何被统一接入?
core 回答的是:
- 一轮 Agent 执行怎么闭环?
tools 回答的是:
- 系统如何把"能做的事"暴露出来并真正执行?
tui 回答的是:
- 用户如何感知这个系统正在干什么?
一旦这么想,模块边界就会非常清楚。
而且这种拆法还有一个很大的收益:它让你能同时照顾到产品视角 和工程视角。
从产品视角,你知道系统最重要的组成部分是什么。 从工程视角,你知道这些组成部分应该如何解耦。
这也是为什么有时候我会觉得,产品设计和架构设计在很多时候并不是两件事。尤其是做 Agent 这种系统的时候,它们几乎就是同一件事的两个面向。
因为你怎么定义产品里的"能力单元",最后几乎一定会反映在你的模块边界和接口设计上。
Provider 这层,是一个很典型的"接口先于实现"的架构练习
在这次 mini-opencode 里,provider 是我最早收的一层。
因为这层特别适合练一件很基本、但非常关键的架构能力:
不要一开始就把自己绑在某个实现上,而是先定义系统真正依赖什么。
所以这版里我没有让上层直接去感知 OpenAI、Anthropic、Gemini 这些具体差异,而是先定了一组很窄的统一接口:
ClientRequestResponseMessageToolCallToolDefinition
这套接口有两个好处。
第一个好处,是把"系统真正依赖什么"说清楚了。
对 core 来说,它根本不关心底下是哪一家模型。它真正关心的只有这些:
- 我有一组 messages
- 我有一组 tools
- 我发起一次 complete
- 我拿回一个 response
- response 里可能带 assistant message,也可能带 tool calls
就够了。
第二个好处,是实现可以换,但上层逻辑不用跟着漂。
当前这版 provider 模块分别适配了 OpenAI、Anthropic、Gemini,底下用的是共享 HTTP client,没有额外引入外部 LLM SDK。
这并不意味着"手写就更好",但它很适合这个阶段。因为这里最重要的,不是把每家能力都吃满,而是先把系统和实现之间那条边界立住。
这其实也是我最近和大家最想强调的一点:
接口设计本身就是在决定系统的长期形状。
如果一开始就让 core、tools、tui 直接依赖具体 provider 细节,那后面整个系统会很快长歪。你也许一开始觉得快,但第二轮扩展时就会明显感受到成本。
Core 这层最重要的,不是复杂,而是把执行主线收得足够直
很多时候一提到 Agent loop,大家很容易联想到复杂的编排、状态机、planner、memory 等等。
但这次做 mini-opencode 的过程中,我反而越来越确认一件事:
在一个 mini 版本里,最重要的不是把 loop 做复杂,而是把主线做直。
所以 core 这层现在干的事情非常集中:
- 管理会话消息
- 执行单轮 turn
- 记录 usage 和事件
- 把进度抛给 UI
当前 runTurn() 的主逻辑非常简单:
- 用户消息进入
Session.Messages - 在
max_steps范围内循环 - 调用 provider
- 记录 assistant message、usage 和进度事件
- 没有
ToolCalls就结束 - 有
ToolCalls就逐个执行 - 执行结果转成 tool message 回写
- 超过步数就报错
这套设计的关键,不在于它"经典",而在于它把整个系统最核心的一条执行链完整地暴露出来了。
也有很多系统难维护的原因不是因为模块拆得不对,而是因为最关键的执行主线藏得太深。你表面上看有很多抽象,但一旦要排查问题,根本不知道状态是怎么流动的。
而现在这版里,turn 继续还是结束,本质上就是看本轮模型响应里有没有 ToolCalls;usage 在每步记录;progress event 在每步抛出;tool result 会显式回到 session。
这就是一个很典型的架构收益:
不是功能更多,而是系统行为更可解释。
Tools 是这次最像"产品设计落到架构设计"的地方
一开始我以为 tools 只是"把能力接进去"。
后来发现不是。
真正写起来之后,tools 很快就变成了整个系统里最像"产品设计与架构设计交叉点"的一层。
因为从产品视角看,tool 就是系统能做什么。 从架构视角看,tool 是系统如何组织外部能力、如何执行、如何治理。
所以这一层我没有做成若干函数拼盘,而是明确拆成了三层:
ToolRegistryInterceptor
这是这次我最满意的一个拆分之一。
先说 Tool
Tool 很朴素,它就是定义"这个能力是什么"和"这个能力怎么执行"。
这层保持简单非常重要。因为工具会越来越多,如果每个工具都夹带一堆框架性逻辑,系统很快会失控。
再说 Registry
Registry 负责注册工具、列出工具定义、按 name 分发执行。 这个抽象看起来很普通,但它解决了一个很关键的问题:
模型看到的能力集合,和系统内部真正可执行的能力集合,是同一个源头。
这意味着能力暴露和能力执行不会出现两套定义。
最后是 Interceptor
这一层是整个工具系统真正开始"像系统"的地方。
因为工具调用不只是一次函数调用,它还常常伴随着:
- 参数校验
- 安全拦截
- 前后状态记录
- 审计或扩展点
- 后续治理能力
所以工具执行真正的顺序变成:
- 解析
ToolCall - 执行
Before() - 执行 tool
- 逆序执行
After() - 返回结果
这套结构的收益非常直接:
- 加新工具不需要改动主循环
- 加安全规则不需要侵入每个工具
- 后续做日志、埋点、审计、缓存,也有明确插点
- tool system 不会因为能力增长而迅速散掉
这点设计的架构收益在于: 不是今天多做了什么,而是明天新增东西时,不会把昨天的结构弄坏。
工具选型这件事,本身也是产品设计
当前默认工具包括:
bashreadwriteeditlistglobgreptodowebfetch
如果只是站在工程实现上,这看起来像一组功能列表。
但如果从产品设计角度看,它其实是一组非常明确的能力选择:
read / write / edit:文件操作闭环list / glob / grep:定位和检索闭环bash:环境执行能力todo:任务状态外显webfetch:把外部网页内容带回本地上下文
也就是说,这不是"多做几个工具",而是在定义:
这个 mini-opencode 要能做哪些事?
而这次设计稿里,对 MVP 的边界其实是非常明确的:支持单轮/多步 Agent 对话、文件读写、shell 执行、todo 管理、网页抓取、多 provider,以及终端交互;不做持久会话、插件 marketplace、token 级 streaming、多会话管理等。
这其实就是一个很标准的产品设计动作:
- 不是什么都做
- 先把主闭环切出来
- 保证每个模块都围绕主闭环服务
而一旦产品边界切得清楚,架构也会跟着变得更自然。
边界落在哪一层,是这次架构实践里另一个很重要的点
做 Agent 很容易碰到一个问题:很多人会把"约束"写在 prompt 里。
这当然不是不行,但一旦系统能:
- 读写文件
- 执行 shell
- 操作工作区
- 抓网页
那很多边界如果还只停留在 prompt 层,其实是非常虚的。
所以当前这版里,我比较在意的是让边界尽量落到运行时:
WorkspaceInterceptor校验bash.working_dirShellSafetyInterceptor拦截危险 shell 片段- 文件工具内部走
SafeJoin() - 工具结果统一渲染成 JSON 再回写给模型
这里你会发现,架构设计和产品设计又一次连到一起了。
从产品角度,这定义了"这个系统允许用户怎么用"。 从工程角度,这定义了"边界到底由谁负责"。
这是一个很典型的设计判断题:
- 是让每个工具各自想办法守边界?
- 还是把共性边界提到系统层?
显然这次我选的是后者。因为对于一个会不断加工具的系统来说,共性边界最好尽量系统化。否则能力一多,安全性和一致性就会迅速下降。
TUI 这层,看起来是 UI,实际上也是架构的一部分
很多人会把 UI 看成最后再补的壳,包括我之前在做 Memo Code 的时候也是这么想。
但这次我越做越觉得,在 Code Agent 场景里,TUI 根本不只是个壳。
因为用户不只是想得到一个结果,他还想知道:
- 系统当前是不是在工作
- 走到第几步了
- 在调用什么工具
- todo 现在是什么状态
- 当前上下文里有什么
所以 tui 模块不是简单把文本打印出来,而是明确承担一件事:
把运行时状态翻译成用户可感知的交互。
当前界面布局很直接:
- 左边
Conversation - 右边
Context - 底部
Composer
同时会显示 token、step、状态文案和 todo;支持 @ 文件补全、queued prompt、草稿恢复、文件选择器,以及小屏时的布局堆叠。
这里最关键的其实不是界面多炫,而是 TUI 和 core 的事件模型是对齐的。core 会抛出 step started / completed、assistant message、tool started / finished 等 progress event,UI 再消费这些事件去刷新状态。
这也是一个非常标准的架构收益:
core不需要知道 UI 怎么渲染tui不需要知道模型协议细节- 两边靠事件接口对齐
这类拆法的好处,在做产品时会特别明显。因为它意味着未来你要换成别的展示层,或者想补更完整的 trace viewer,并不需要把 loop 和 provider 重新打散。
这次做完之后,我对"架构实践"这件事反而更有体感了
如果只把这次 mini-opencode 看成"我又做了一个 Agent demo",大可不必如此浪费。
因为它更有价值的地方在于:它几乎把一套小型系统里最典型的架构问题,比较完整地暴露出来了。
比如:
- 模块到底按什么维度拆
- 接口应该先于实现,还是边写边收
- 主执行链要不要尽量直
- 产品能力边界如何映射成工具集合
- 系统级约束该不该提到运行时
- UI 和运行时之间应该怎样对接
而 mini-opencode 这次很妙的一点在于,它不是一个抽象案例,而是一个可以真做、真跑、真迭代的具体系统。你每做一层,都会立刻感受到一个架构判断到底是不是靠谱。
这比只在白板上讲"应该高内聚低耦合""应该接口隔离""应该职责清晰",有效得多。
因为真正的架构能力,从来都不是背原则,而是:
你能不能在一个具体系统里,做出那些让后续演进更顺的拆分和接口。
从这个角度看,这次 Go mini-opencode 对我来说,确实是一场很好的架构实践。
不是因为它多复杂。 而是因为它刚好足够完整:有主流程、有能力系统、有状态、有交互、有边界、有扩展点。
这样的系统最适合练基本功。
当然,这仍然是一个很初步的 mini-opencode
当前也还有一些边界非常明确:
glob目前不支持把**当递归匹配grep现在返回的是匹配行,context参数还没展开成上下文块Context面板当前更聚焦状态和 todo,不单独展示完整 trace- provider 还是 request/response 式,没有做 token 级 streaming
但我反而觉得,这种边界清楚是好事。
因为一个架构实践里,最怕的不是"还没做完",而是"系统边界已经糊了"。只要边界清楚,下一步该补什么、该在哪层补,都是清晰的。
这其实也是我最近越来越喜欢的一种做法:
先做一个结构清楚的 mini 版本。 功能可以少一点,但形状要正。
因为一旦形状正了,后面很多东西都是顺着长出来的。
(完)