从零写一个 AI 编程助手:MiniCode 的设计笔记

Infy AI Lv2

做 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 精准修改。

工具系统的接口长这样:

1
2
3
4
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 工具用的是字符串精确匹配,不是行号。

1
2
3
4
5
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 天然适合这个场景:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 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),下次遇到相同操作直接放行:

1
2
3
case Persistent:
key := toolName + ":" + action
s.persistent[key] = true

流式输出:打字机效果背后

流式输出看起来像是个 UI 特性,但实现起来涉及并发。

agent.Stream() 跑在独立 goroutine 里,通过回调把数据推给 TUI:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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()

1
2
3
if msg.Type == tea.KeyEsc && m.streaming {
m.cancelFunc()
}

agent.Stream() 里的网络请求会感知到 context 被取消,返回 context.Canceled 错误。TUI 收到 streamDoneMsg 时检查是不是取消,是的话回滚历史:

1
2
3
if errors.Is(msg.err, context.Canceled) {
m.history = m.history[:len(m.history)-1]
}

TUI:Elm 架构让状态管理不至于失控

Bubble Tea 强制你用 Elm 架构:

1
Model(状态) + Update(消息 → 新状态) + View(状态 → 字符串)

Update 是纯函数,所有副作用都用 tea.Cmd 表达(本质上是一个返回消息的函数)。流式请求、文件读取、权限确认,都是 Cmd。

1
2
3
4
5
6
7
8
9
10
11
12
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

  • 标题: 从零写一个 AI 编程助手:MiniCode 的设计笔记
  • 作者: Infy AI
  • 创建于 : 2026-03-31 10:00:00
  • 更新于 : 2026-03-31 08:58:27
  • 链接: https://www.rasior.com/2026/03/31/20260331_minicode-design-notes/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。