从零写一个 AI 编程助手:MiniCode 的设计笔记
做 MiniCode 这个项目的起因很简单:我想真正搞懂 Claude Code 是怎么工作的。
读源码没有,代码不开源。看教程,全是 Python + LangChain 的 Hello World,看完还是不知道一个真实的编程助手内部长什么样。所以就自己写了一个——MiniCode,用 Go,3000+ 行,能用。
这篇文章不是文档,是记录几个做的时候觉得有意思的设计决策。
工具系统:Agent 能做什么取决于你给它什么
最开始我以为 AI Agent 的核心是模型有多强。做完之后发现,工具设计才是最重要的部分。
MiniCode 有六个工具:glob(文件搜索)、view(文件查看)、grep(内容搜索)、bash(命令执行)、write(文件写入)、edit(代码编辑)。
这六个工具对应的是一个程序员操作文件系统的基本动作,没有更多了。但 Agent 能用这六个工具做相当复杂的事:它可以先 glob 找相关文件,grep 搜索关键词,view 看上下文,然后 edit 精准修改。
工具系统的接口长这样:
func GlobTool(ctx context.Context, input GlobInput, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
matches, _ := doublestar.FilepathGlob(pattern)
return fantasy.NewTextResponse(strings.Join(matches, "\n")), nil
}
每个工具就是一个普通函数。Fantasy SDK 负责把函数签名转换成 JSON Schema 告诉模型,处理模型返回的调用请求,把结果塞回对话历史。
这里有个细节值得说:edit 工具用的是字符串精确匹配,不是行号。
count := strings.Count(originalContent, input.OldString)
if !replaceAll && count > 1 {
return error("匹配到多处,请加更多上下文")
}
newContent := strings.Replace(originalContent, old, new, 1)
为什么不用行号?因为 LLM 数行号不可靠。模型看到的文件内容里有行号前缀,它可能数错,或者在多轮对话后行号已经变了。字符串匹配虽然要求模型复制一段原文,但更稳定。Claude Code 也是这个思路。
权限系统:用 channel 阻塞代替轮询
这是整个项目里我最满意的一个设计。
问题是这样的:Agent 在执行 bash 命令之前需要用户确认。但 Agent 跑在一个 goroutine 里,TUI 在主线程,怎么让 Agent “等待”用户按键?
最直觉的做法是加个回调函数,或者让 Agent 定期轮询一个 flag。但这样代码会变得很丑。
Go 的 channel 天然适合这个场景:
// Agent goroutine 里
func (s *Service) Request(toolName, action, desc string) bool {
req := &Request{
ToolName: toolName,
ResponseCh: make(chan Response, 1),
}
s.program.Send(permissionRequestMsg{req})
// 阻塞在这里,等用户响应
resp := <-req.ResponseCh
return resp == Granted || resp == Persistent
}
// TUI 主线程里
func (m *Model) handlePermissionKey(key string) {
switch key {
case "y":
m.permService.Respond(m.permPending, Granted)
case "n":
m.permService.Respond(m.permPending, Denied)
case "a":
m.permService.Respond(m.permPending, Persistent)
}
}
Agent goroutine 发一个权限请求消息给 TUI,然后阻塞在 channel 上。TUI 显示确认对话框,用户按键,往 channel 里写一个响应,Agent goroutine 恢复执行。
整个流程线性、清晰,没有回调嵌套,没有状态机,没有轮询。代码读起来就像同步代码。
还加了持久授权(按 a),下次遇到相同操作直接放行:
case Persistent:
key := toolName + ":" + action
s.persistent[key] = true
流式输出:打字机效果背后
流式输出看起来像是个 UI 特性,但实现起来涉及并发。
agent.Stream() 跑在独立 goroutine 里,通过回调把数据推给 TUI:
result, err := m.agent.Stream(ctx, fantasy.AgentStreamCall{
Messages: m.history,
Prompt: input,
OnTextDelta: func(id, text string) error {
m.program.Send(streamTextMsg{delta: text})
return nil
},
OnToolCall: func(tc fantasy.ToolCallContent) error {
m.program.Send(streamToolCallMsg{name: tc.ToolName})
return nil
},
OnStreamFinish: func(usage fantasy.Usage, ...) error {
m.program.Send(streamTokenUpdateMsg{tokens: int(usage.TotalTokens)})
return nil
},
})
m.program.Send() 是 Bubble Tea 的线程安全接口,可以从任意 goroutine 向主事件循环发消息。TUI 主循环收到 streamTextMsg 后追加文本、刷新渲染,就是打字机效果。
取消也很干净。每次开始流式请求都创建一个 context.WithCancel,Esc 键触发 cancelFunc():
if msg.Type == tea.KeyEsc && m.streaming {
m.cancelFunc()
}
agent.Stream() 里的网络请求会感知到 context 被取消,返回 context.Canceled 错误。TUI 收到 streamDoneMsg 时检查是不是取消,是的话回滚历史:
if errors.Is(msg.err, context.Canceled) {
m.history = m.history[:len(m.history)-1]
}
TUI:Elm 架构让状态管理不至于失控
Bubble Tea 强制你用 Elm 架构:
Model(状态) + Update(消息 → 新状态) + View(状态 → 字符串)
Update 是纯函数,所有副作用都用 tea.Cmd 表达(本质上是一个返回消息的函数)。流式请求、文件读取、权限确认,都是 Cmd。
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case streamTextMsg:
m.streamParts = append(m.streamParts, streamPart{text: msg.delta})
m.viewport.SetContent(m.renderMessages())
return m, nil
case permissionRequestMsg:
m.permPending = msg.req
return m, nil
// ...
}
}
好处是状态转移完全可追踪,不会出现”这个状态是怎么变成这样的”这种困惑。坏处是消息类型会越来越多,Update 函数会变长。现在 MiniCode 的 Update 大概有 300 行,已经开始有点难找了。
关于 Go 的选择
为什么用 Go 不用 Python?
主要是因为 Go 的并发模型让上面这些设计实现起来更自然。goroutine + channel 处理”Agent 线程和 UI 线程通信”这类问题,代码非常直接。Python 做同样的事需要 asyncio 或者 threading,会复杂一些。
另外 Go 单二进制部署,不用管依赖环境,用户 go run . 就能跑起来。
当然 Python 生态在 AI 这块确实更成熟。如果要做复杂的上下文压缩、长期记忆这些,Python 的库会方便很多。MiniCode 目前的上下文管理非常简单——就是把历史全部追加,对话够长了就会超出 token 限制。这是后面要解决的问题。
项目还在更新,计划把 30 天的教程写完。目前到 Day 7(配置系统),后面会覆盖 Agent 循环、上下文压缩、子 Agent 这些。感兴趣的话可以看 GitHub。
Enjoy Reading This Article?
Here are some more articles you might like to read next:
- 一次接口文档站工程化实践:VitePress、Swagger 与 Cloudflare 部署踩坑记录
- 从插件系统到微内核平台:一篇从入门到进阶的 NocoBase 架构笔记
- 从 Mini NocoBase Demo 看无代码平台的微内核与插件化设计
- 给 FastAPI 后台模板补了一轮生产化能力
- 现代大语言模型的架构细节:从 RMSNorm 到 Loss 计算
- 强化学习算法笔记:用一套框架串起 MC、TD、DQN、PPO、SAC
- 一个分布式锁没能拦住的重复下发问题
- 搞清楚 BatchNorm、LayerNorm、RMSNorm 到底在干嘛
- 强化学习基础笔记
- FastAPI-Template 实践笔记:以依赖注入管理请求生命周期