开场:先别急着写循环
决定动手写一个 coding agent 的那个周末,我兴冲冲地新建了一个目录,第一个文件就叫 agent.go,第一行代码就是 for {。
我想的是:第 02 篇说了,Agent 就是一个循环嘛,先把循环写出来,再往里填东西。
两个小时后,我的 agent.go 有四百行:HTTP 请求、JSON 解析、工具执行、终端输出、错误处理,全部搅在那个 for 循环里。它能跑,但我想加一个功能时,发现改哪里都会碰到别的东西;想写个测试,发现每个测试都得真的调一次 API,又慢又花钱。
我把它删了,重新开始。删掉四百行自己写的代码,比想象中难受,但比在它上面继续堆东西要好得多。这一次我先没写循环,而是想清楚了三件事:分几层,每层管什么,层和层之间用什么接口说话。 想清楚之后,循环反而是最好写的部分。
从这一篇开始,是整个系列的核心:第五部分,手写 Agent 篇。 八篇文章,我们用 Go 从零写一个能读代码、改代码、跑命令的迷你 coding agent。它就是配套仓库里的 mini-agent,完整的代码都在 cmd/mini-agent 和 agent 目录里,每一篇讲其中的一部分。
第一篇的目标很朴素:调通 API,跑起多轮对话。 读完本篇,你应该能回答这几个问题:
- 一个 Agent 项目应该怎么分层?
- 调用模型 API,最少需要哪些代码?
- 消息历史用什么数据结构?为什么?
- system prompt 怎么注入?里面放什么?
- 一个能用的 REPL 需要处理哪些细节?
一、项目结构:三层,互不越界
1.1 三层分工
mini-agent 的代码分成三层:
终端交互:读输入、打印输出、处理命令和 Ctrl+C"] --> A["agent
对话状态:消息历史、循环、工具执行、权限"] A --> L["internal/llm
发一次请求:拼请求、发 HTTP、解析响应"] L --> D["DeepSeek API
(或离线演示模型)"] style C fill:#e3f2fd,stroke:#1565c0 style A fill:#fff3e0,stroke:#e65100 style L fill:#e8f5e9,stroke:#2e7d32
internal/llm:只管"发一次请求"。 给它一个请求,它返回一个响应。它不知道什么是对话,不知道什么是工具执行,不知道终端的存在;agent:只管"对话状态"。 它维护消息历史,跑那个循环,执行工具。它不知道请求是怎么发出去的,也不知道输出最终打印在哪里;cmd/mini-agent:只管"终端交互"。 读用户的输入,打印模型的输出,处理/reset这样的命令和 Ctrl+C。
三层互不越界。 这个约束看起来有点死板,但它带来了两个巨大的好处:可以替换,可以测试。
判断有没有越界,有两个很简单的办法。一是看 import:internal/llm 不应该 import agent,agent 不应该 import cmd 里的任何东西。二是搜一搜 agent 目录:里面可以用 fmt.Sprintf 拼字符串,但不应该出现一句 fmt.Println 直接往终端打印,也不应该出现 os.Stdin。所有要给人看的东西,都通过 UI 接口交出去,由 cmd 那一层决定怎么显示。
1.2 层与层之间的接口
关键在于层与层之间用接口说话,而不是直接依赖具体的实现。
agent 层依赖的"模型",是 internal/llm 里定义的一个接口,只有一个方法:
// Model 是"大模型"这一层的全部抽象:给一次请求,返回一次响应。
// Agent 和测试都只依赖这个接口,测试里可以换成脚本化的假模型(llmtest.Fake)。
type Model interface {
Create(ctx context.Context, req *Request) (*Response, error)
}agent 层依赖的"界面",是它自己定义的一个接口:
// UI 是 Agent 和外界(终端、测试、父 Agent)打交道的接口。
type UI interface {
// Text 输出模型说的一段完整的话(非流式)。
Text(s string)
// TextDelta 输出流式生成中的一小段文字(第 29 篇)。
TextDelta(s string)
// ToolCall 在执行工具之前调用。
ToolCall(name string, input json.RawMessage)
// ToolResult 在工具执行完之后调用。
ToolResult(name string, output string, isError bool)
}有了这两个接口:
- 模型可以替换:真实的 DeepSeek、离线的演示模型、测试用的假模型,
agent层一行都不用改; - 界面可以替换:终端里的彩色输出、headless 模式下的 JSON 输出(第 33 篇)、子 Agent 把输出交给父 Agent(第 30 篇)、测试里把输出记下来检查;
- 可以测试:测试里用一个按剧本出牌的假模型,不需要 API Key,不花钱,结果完全确定。
二、调通 API
2.1 一次请求,就是一次 HTTP POST
不管背后是哪家的模型,"调用一次模型"在 HTTP 层面就是:往一个地址 POST 一段 JSON,读回一段 JSON。 配套仓库的 DeepSeek 客户端,发请求的核心代码在 internal/llm/deepseek.go 里:
func (d *DeepSeek) post(ctx context.Context, body *dsRequest) (*http.Response, error) {
buf, err := json.Marshal(body)
if err != nil {
return nil, err
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(d.BaseURL, "/")+"/chat/completions", bytes.NewReader(buf))
if err != nil {
return nil, err
}
httpReq.Header.Set("content-type", "application/json")
httpReq.Header.Set("authorization", "Bearer "+d.APIKey)
resp, err := d.HTTP.Do(httpReq)
if err != nil {
return nil, err
}
if resp.StatusCode/100 != 2 {
defer resp.Body.Close()
return nil, parseAPIError(resp)
}
return resp, nil
}就这么多:序列化,设置两个请求头,发出去,检查状态码。整个仓库没有引入任何模型厂商的 SDK,只用了 Go 的标准库。 这是有意为之:这个系列的目的,就是看清每一层到底发生了什么。生产项目当然可以用官方 SDK,但你知道了 SDK 里面是什么,用起来心里就有底了。
几个值得注意的细节:
- 用
NewRequestWithContext:把context传进去,用户按 Ctrl+C 时,请求能被立刻取消,而不是傻等它返回; - HTTP 客户端设了超时:
&http.Client{Timeout: 10 * time.Minute}。模型生成一段长回答可能要好几分钟,但不能无限等下去; - 非 2xx 的响应解析成
APIError:带上状态码、错误类型、错误信息,还有retry-after头。第 29 篇的重试逻辑要靠它判断"这个错误值不值得重试"。
请求体和响应体在仓库内部格式和 DeepSeek 格式之间的翻译,第 19 篇已经拆过了,在 deepseek_format.go 里。
2.2 从环境变量构造
实验程序和 mini-agent 拿到的模型,是一个 *llm.Client,它根据环境变量决定背后用什么:
func NewFromEnv() (*Client, error) {
if key := os.Getenv("DEEPSEEK_API_KEY"); key != "" {
return &Client{Model: envOr("AGENT_MODEL", DeepSeekDefaultModel), Backend: newDeepSeek(key)}, nil
}
d, err := newDemoFromEnv()
// ……
return &Client{Model: envOr("AGENT_MODEL", DefaultModel), Backend: d}, nil
}设置了 DEEPSEEK_API_KEY,就是真实的 DeepSeek;没设置,就是离线的演示模型。代码完全一样,只是背后换了一个实现。这也是"层与层之间用接口说话"的一个直接收益。
2.3 最小的一次调用
有了这一层,调用一次模型只需要几行:
c := llm.MustFromEnv()
resp, err := c.Create(ctx, &llm.Request{
System: llm.SystemText("你是一个有帮助的助手。"),
Messages: []llm.Message{llm.UserText("你好")},
MaxTokens: 1024,
})
fmt.Println(resp.Text())第 01 篇的实验 1,就是这几行。
2.4 演示模型:没有 Key 也能跑起来
Backend 的另一个实现是离线的演示模型,规则都写在 internal/llm/reply.go 里。它值得单独说两句,因为它本身就是"接口带来可替换性"的一个例子。
它不理解任何内容,只看请求的形状:
- 请求里要求按 JSON Schema 输出,它就照着 schema 编一个 JSON(第 04 篇);
- 请求里带了工具,它就按一个固定的顺序调用:有
list_files和read_file就先列目录、再读文件;工具都调过一轮了,就把看到的结果概括一下作为最终回答; - 请求里有
Observation:这个停止序列,它就按 ReAct 格式输出(第 03 篇); - 什么都没有,就回一句"这次请求里一共有 N 条消息,最后一条是……"。
这套规则只有一个目的:让每一篇的实验在没有 Key 的情况下也能把流程跑通。 它从不主动调用会改文件、跑命令的工具,所以你在任何目录里启动 mini-agent 都是安全的。
然后总结"] Q -->|"有 Observation: 停止序列"| A["ReAct 格式的一步"] Q -->|"都没有"| X["这次请求里有 N 条消息"] style Q fill:#fff9c4
对写 Agent 的人来说,演示模型最大的用处不是省钱,而是确定:同样的输入,它的行为是可以预测的。调界面、调 REPL、调权限确认的流程时,不用每次都等真实模型想上几秒、花上几分钱,也不用担心它这次突然换了一个做法。等流程调顺了,再换上真实模型看效果。
三、消息历史:Agent 的全部状态
3.1 数据结构
第 02 篇说过:Agent 的全部状态就是消息历史。 在 mini-agent 里,它就是 Agent 结构体里的一个切片:
// Agent 的全部状态就是 Messages。第 02 篇说过:把它存下来,Agent 就能在任何地方"复活"。
type Agent struct {
Model llm.Model
System string
Tools *tools.Registry
// ……
Messages []llm.Message
Steps int // 累计调用了多少次模型(不含子 Agent)
Usage llm.Usage // 累计用量,包括子 Agent 的
// ……
}每条消息有一个角色和一组内容块:
type Message struct {
Role string `json:"role"`
Content []ContentBlock `json:"content"`
}为什么 Content 是一组"块",而不是一个字符串?因为一条消息里可能同时有好几种东西:一段文字、一段思考、两个工具调用;或者三个工具结果加一段用户补充的话。用一组带类型的块来表示,比塞进一个字符串里再解析清楚得多。第 19 篇讲过,DeepSeek 用的 OpenAI 格式是另一种组织方式,翻译层负责转换,agent 层只看到统一的内容块。
3.2 保持 user 和 assistant 交替
第 02 篇讲过一条铁律:user 和 assistant 必须严格交替。 这条规则在 Agent 里会遇到一个很现实的问题:请求失败了怎么办?
用户说了一句话,Agent 把它追加进历史,发请求,结果网络断了。这时候历史的最后一条是 user 消息。用户再说一句话,如果直接追加,历史里就有两条连续的 user 消息,下一次请求会被拒绝。
mini-agent 的处理方式是:如果最后一条已经是 user 消息,就把新内容合并进去,而不是新开一条。
// appendUser 追加一条 user 消息;如果最后一条已经是 user(比如上一轮中途出错),就合并进去,
// 保证 user / assistant 严格交替。
func (a *Agent) appendUser(blocks ...llm.ContentBlock) {
if n := len(a.Messages); n > 0 && a.Messages[n-1].Role == llm.RoleUser {
a.Messages[n-1].Content = append(a.Messages[n-1].Content, blocks...)
return
}
a.Messages = append(a.Messages, llm.Message{Role: llm.RoleUser, Content: blocks})
}这样,上一次没发出去的那句话不会丢,新的这句话也接上了,交替的规则也没被破坏。用户的输入、工具的结果,统一通过 appendUser 追加。
3.3 Send:一轮对话
Agent.Send 处理用户的一轮输入。完整的版本里有工具执行、压缩、权限这些后面几篇才讲的东西,把它们都去掉,骨架是这样的:
// Send 处理一轮用户输入:调用模型 → 执行工具 → 把结果喂回去 → 再调用模型……直到模型说做完了。
func (a *Agent) Send(ctx context.Context, input string) (string, error) {
a.appendUser(llm.TextBlock(input))
for step := 0; step < a.MaxSteps; step++ {
resp, err := a.call(ctx, &llm.Request{
System: llm.SystemText(a.System),
Messages: a.Messages,
Tools: a.Tools.Definitions(),
MaxTokens: a.MaxTokens,
})
if err != nil {
return "", err
}
a.Steps++
a.addUsage(resp.Usage)
a.Messages = append(a.Messages, resp.AsMessage())
if resp.StopReason != "tool_use" {
return resp.Text(), nil
}
// ……执行工具,结果 appendUser 回去(第 25 篇)
}
return "", ErrMaxSteps
}如果还没有任何工具,模型永远不会返回 tool_use,循环第一圈就会结束,Send 就退化成了第 01 篇的 Chatbot:说一句,回一句。 第 25 篇给它加上第一个工具,这个循环才会真正转起来。
注意每一次请求都是 Messages: a.Messages,完整的历史。这就是第 01 篇说的"不是模型记得,是应用每次都把聊天记录完整地重新发给它"。
3.4 两个上限:MaxTokens 和 MaxSteps
New 创建 Agent 时设了两个默认值:MaxTokens: 16000,MaxSteps: 50。它们管的是两件完全不同的事:
MaxTokens管一次回复能有多长。它是请求里的一个参数,告诉模型"这一次最多输出这么多 token"。给 16000,是因为第 12 篇说过:开着思考的时候,思考的 token 也算在里面,给少了,思考就会把真正的回答挤掉;MaxSteps管一轮对话最多调用几次模型。它是 harness 自己的刹车,第 02 篇那句"步数用完了,放弃",在这里变成了ErrMaxSteps。
这两个数字没有标准答案。50 步对"修一个小 bug"足够了,对"重构一个模块"可能不够;但如果一个任务转了 50 圈还没做完,多半已经出了问题(第 39 篇),这时候停下来让人看一眼,比让它继续转更划算。
3.5 存档:二十行就能"复活"
第 02 篇说过:把 messages 存下来,Agent 就能在任何地方复活。 mini-agent 没有实现这个功能,但你可以算一算它要多少代码:
- 退出时,
json.Marshal(a.Messages),写进.mini-agent/session.json; - 启动时加一个
-resume参数,读回这个文件,json.Unmarshal进a.Messages; - 恢复之后,第一句话照常
Send。
不到二十行。之所以能这么简单,正是因为 3.1 节那句话:Agent 的全部状态就是 Messages。 如果你把一部分状态藏在了别的地方(比如一个全局变量里记着"用户刚才让我读了哪个文件"),存档时就得把它们也找出来一起存,恢复时还得保证它们和消息历史是一致的。
有两个细节要注意:一是思考块要原样存、原样恢复,因为 DeepSeek 要求带工具的对话里,之前的 reasoning_content 必须原样回传(第 12 篇);二是存档文件里有你和模型的全部对话,可能包括它读过的文件内容,别把它提交进 git。
四、system prompt:注入什么,怎么注入
4.1 拼装
mini-agent 的 system prompt 不是一个写死的字符串,而是几段拼起来的。agent/prompt.go:
// SystemPrompt 生成 system prompt:固定的基础部分 + 额外的段落(项目记忆、skills……)+ 环境信息。
//
// 第 01 篇说过:网页版知道今天几号,是因为应用偷偷写进了 system prompt。我们也照做。
// 越稳定的内容越靠前,带日期的环境信息放在最后,方便第 37 篇的 prompt caching。
func SystemPrompt(workdir string, sections ...string) string {
parts := []string{basePrompt}
for _, s := range sections {
if strings.TrimSpace(s) != "" {
parts = append(parts, s)
}
}
return strings.Join(append(parts, EnvInfo(workdir)), "\n\n")
}拼出来是四段:
你是谁、怎么工作、什么风格"] --> M["项目记忆
AGENTS.md(第 17 篇)"] M --> S["skills 列表
(第 31 篇)"] S --> E["环境信息
工作目录、操作系统、今天日期"] style B fill:#e3f2fd,stroke:#1565c0 style E fill:#fff9c4
顺序是有讲究的:越稳定的越靠前。 基础 prompt 永远不变,项目记忆很少变,skills 列表偶尔变,环境信息里有日期,每天都变。第 10 篇说过,缓存要求前缀一模一样,把会变的放在最后,前面那些不变的部分就能稳定地命中缓存。
4.2 环境信息
// EnvInfo 是运行环境的描述,主 Agent 和子 Agent 共用。
func EnvInfo(workdir string) string {
return fmt.Sprintf(`<env>
工作目录:%s
操作系统:%s/%s
今天日期:%s
</env>`, workdir, runtime.GOOS, runtime.GOARCH, time.Now().Format("2006-01-02"))
}三样东西,每一样都对应一种模型自己无法知道的信息:
- 工作目录:它在哪里工作。读文件、跑命令的相对路径都基于它;
- 操作系统:在 macOS 上
sed -i的写法和 Linux 不一样,它得知道; - 今天日期:第 01 篇的老问题。不告诉它,它不知道今天是哪天,也意识不到自己的知识可能过时了(第 13 篇)。
日期只精确到天,不精确到秒。精确到秒的话,每次请求的 system prompt 都不一样,缓存就永远不命中了(第 10 篇的坑 3)。
4.3 基础 prompt
基础 prompt 第 15 篇已经展示过了,它告诉 mini-agent 怎么工作:先读再改,以读到的内容为准,改完要验证,不确定就说不确定。随着后面几篇加上更多工具,它也会跟着长出相应的规则。
五、REPL:一个能用的终端界面
5.1 主循环
cmd/mini-agent/main.go 的最后是一个经典的 REPL(Read-Eval-Print Loop):
for {
fmt.Print("\n> ")
if !in.Scan() {
return
}
line := strings.TrimSpace(in.Text())
switch line {
case "":
continue
case "/exit", "/quit":
return
case "/reset":
a.Reset()
fmt.Println("(对话已清空)")
continue
case "/usage":
// ……打印消息数、上下文大小、累计用量和费用
continue
// ……/cost、/compact
}
// Ctrl+C 只打断当前这一轮,不退出程序。
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
if _, err := a.Send(ctx, line); err != nil {
fmt.Fprintln(os.Stderr, "错误:", err)
}
cancel()
}几个让它"能用"而不只是"能跑"的细节:
斜杠命令。 以 / 开头的输入不发给模型,而是由程序自己处理:/reset 清空对话(第 16 篇的"清空"),/usage 看用量,/compact 压缩上下文(第 28 篇),/cost 看账本(第 37 篇),/exit 退出。
Ctrl+C 只打断当前这一轮。 这一点特别重要。Agent 跑着跑着走偏了,你想叫停它,按下 Ctrl+C,如果整个程序直接退出,之前的对话就全没了。signal.NotifyContext 为每一轮创建一个"按 Ctrl+C 就会被取消"的 context,取消之后,正在进行的 HTTP 请求会立刻中断(因为 2.1 节传了 context),Send 返回,回到 REPL 等你的下一句话。对话历史还在。
大输入。 bufio.Scanner 默认一行最多 64KB,用户粘贴一大段日志进来就会出错。所以创建时设了一个更大的缓冲区:
in := bufio.NewScanner(os.Stdin)
in.Buffer(make([]byte, 1024*1024), 1024*1024)5.2 终端 UI
terminalUI 实现了 agent.UI 接口,负责把各种事件打印成人看得舒服的样子:
func (terminalUI) ToolCall(name string, input json.RawMessage) {
fmt.Printf("\n\033[36m⏺ %s\033[0m %s\n", name, oneLine(string(input), 120))
}工具调用用青色显示,参数压成一行、最多 120 个字符。工具结果用灰色,出错时用红色。这些 \033[36m 是终端的颜色控制码。
界面不影响逻辑。 想换成别的样子,只需要再写一个实现了 UI 接口的类型,agent 层完全不用动。
5.3 Reset
// Reset 清空对话,相当于常见 coding agent 里的 /clear。
func (a *Agent) Reset() {
a.Messages = nil
a.lastContext = 0
}清空对话,就是把消息历史设成空。因为 Agent 的全部状态就是消息历史,这一行就够了。
六、测试:不花一分钱
6.1 假模型
三层分离的最大回报,是测试。配套仓库有一个按剧本出牌的假模型 internal/llm/llmtest/fake.go:
// Fake 依次返回 Responses 里的响应,并记录收到的每一个请求。
type Fake struct {
mu sync.Mutex
Responses []*llm.Response
Requests []*llm.Request
}你给它准备好一组响应,它按顺序返回;同时它会记下收到的每一个请求,测试里可以检查"Agent 到底发了什么"。
6.2 测多轮对话
agent/agent_test.go 里的第一个测试,检查的就是第 01 篇那个核心认知:
// 多轮对话:第二轮请求必须带上第一轮的完整历史。
func TestMultiTurnSendsFullHistory(t *testing.T) {
fake := &llmtest.Fake{Responses: []*llm.Response{
llmtest.Text("你好乔人尹!"),
llmtest.Text("你叫乔人尹。"),
}}
a := New(fake, "system", nil, &recordUI{})
a.Send(ctx, "我叫乔人尹")
a.Send(ctx, "我叫什么?")
second := fake.Requests[1]
if len(second.Messages) != 3 {
t.Fatalf("第二轮应带 3 条消息,实际 %d 条", len(second.Messages))
}
// ……还检查了第一条消息的内容、system prompt 有没有注入、用量有没有累加
}第二次请求必须带着 3 条消息:第一轮的 user、第一轮的 assistant、第二轮的 user。
还有一个测试专门检查 3.2 节的"请求失败后合并"逻辑:第一次请求失败,第二次请求成功,最终历史里应该只有 2 条消息,第一条 user 消息里有 2 个内容块。
go test ./agent/...所有测试都不需要 API Key,不联网,几秒钟跑完。 后面每一篇加功能,都会配上对应的测试。
6.3 测"它发了什么",而不是"它说了什么"
注意这两个测试检查的东西:第二轮请求里有几条消息,第一条 user 消息里有几个内容块。它们检查的都是 Agent 发给模型的请求,而不是模型的回答。
这是测试 Agent 的一个核心思路。模型的回答是假模型按剧本给的,检查它没有意义;Agent 自己的逻辑,体现在它怎么组织每一次请求上:历史有没有带全、system prompt 有没有注入、工具结果有没有按 id 配对、出错之后历史有没有被弄坏。llmtest.Fake 把每一次请求都记在 Requests 里,就是为了让测试可以一条一条地检查它们。
第 02 篇说调试 Agent 的第一原则是"看它那一步收到的 messages",写测试也是同一个原则:断言 messages。
七、动手:跑起来
实验 1:不设 Key,先跑起来
go run ./cmd/mini-agent不设 Key 时用的是离线演示模型。启动后会打印模型名、权限模式、工作目录、工具数量。随便说一句话,演示模型会告诉你"这次请求里一共有 N 条消息";多说几句,看着这个数字增长。输入 /usage 看看当前的上下文有多大,/reset 清空,再 /usage 一次。
(mini-agent 已经有了后面几篇才讲的工具,所以演示模型可能会先去调用 list_files 和 read_file。这是第 25 篇的内容,先不管它。在线 Playground 跑不了 mini-agent,请在本地或 Codespaces 里运行。)
实验 2:接上 DeepSeek
export DEEPSEEK_API_KEY=sk-...
go run ./cmd/mini-agent做一遍第 01 篇的实验:
- 先说"我叫张三",再问"我叫什么"。它记得,因为历史每次都发过去了;
- 输入
/reset,再问"我叫什么"。它不知道了,因为历史清空了; - 问"今天几号"。它知道,因为 system prompt 里写着。
在它回答到一半的时候按一下 Ctrl+C,看看程序是不是只打断了这一轮,然后你还能接着问。
实验 3:把历史打出来看看
在 mini-agent 里和它聊几轮之后,想看看它此刻的 Messages 到底长什么样,最直接的办法是临时在 REPL 里加一个 /dump 命令:json.MarshalIndent(a.Messages, "", " "),打印出来。对照 3.1 节的数据结构,看看每条消息的角色和内容块;如果接了 DeepSeek,还能看到 assistant 消息里那个 thinking 块。这也是 3.5 节存档功能的第一步:能打印出来,就能存下来。
实验 4:读代码
按这个顺序读,每个文件都不长:
- internal/llm/client.go:
Model接口和Client; - agent/agent.go:
UI接口、Agent结构体、Send、appendUser; - agent/prompt.go:system prompt 的拼装;
- cmd/mini-agent/main.go:
terminalUI和最后的 REPL; - agent/agent_test.go:前两个测试。
八、踩坑记录:调通 API 时的七个坑
坑 1:所有代码写在一个循环里。
开场的故事。HTTP、JSON、工具、终端搅在一起,改不动,也测不了。先分层,再写循环。
坑 2:请求失败后出现两条连续的 user 消息。
网络抖一下,下一次请求就因为"角色没有交替"被拒绝,而且之后每一次都会被拒绝,因为历史已经坏了。用 appendUser 合并,或者失败时把刚追加的消息撤掉。
坑 3:把状态存在消息历史之外。
比如用一个全局变量记"用户叫什么"。这样 /reset 清不干净,存档恢复时也恢复不了。Agent 的状态只放在消息历史里,需要额外记录的(用量、步数)是统计信息,不是状态。
坑 4:system prompt 里放精确到秒的时间。
缓存永远不命中。第 10 篇的坑 3,在这里第一次真正落到代码里。
坑 5:Ctrl+C 直接退出程序。
用户想打断一下,结果整个对话没了。用 signal.NotifyContext 为每一轮创建可取消的 context,并且把 context 一路传到 HTTP 请求里。
坑 6:把 API Key 打进日志。
调试时为了看请求,把整个 HTTP 请求打印出来,authorization 头也在里面。日志一旦被上传、被分享,Key 就泄露了。打印请求时,要么只打 body,要么把认证头抹掉。
坑 7:把"用户按了 Ctrl+C"当成错误。
用户主动打断,Send 会返回一个 context canceled 的错误。最要紧的是别让重试逻辑(第 29 篇)把它当成"可以重试的网络错误",否则用户按了 Ctrl+C,它却又发了一次请求。配套仓库的 internal/llm/retry.go 专门判断了 context.Canceled,遇到它直接放弃重试。
不过 mini-agent 的 REPL 在这一点上还偷了懒:它把所有错误一视同仁地打印成"错误: context canceled"。功能上没问题,对话历史也还在,但用户看到"错误"两个字,难免以为程序出了毛病。这是一个三行就能改好的小练习:用 errors.Is(err, context.Canceled) 识别出来,打印一句"(已打断)",安静地回到提示符。
一句话总结
写 Agent 先分层:internal/llm 只管发一次请求,agent 只管对话状态,cmd 只管终端交互,层与层之间用接口说话;调用模型就是一次 HTTP POST,消息历史是唯一的状态,system prompt 由稳定的部分在前、带日期的环境信息在后拼成;这时候它还只是个 Chatbot,但已经可以不花一分钱地测试了。
cmd/mini-agent"] R --> A["Agent.Send
消息历史 + 循环"] A --> M["llm.Model
一次 HTTP POST"] M --> A A --> R T["llmtest.Fake"] -.->|"测试时替换"| M style A fill:#fff3e0,stroke:#e65100
评论