手写 Agent ①,调通 API,跑起多轮对话

从分层结构开始用 Go 搭建 mini-agent,接通模型 API、维护多轮消息历史并实现终端交互和离线测试。

outlier 18 分钟 6891 字 1 0

开场:先别急着写循环

决定动手写一个 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 的代码分成三层:

flowchart TB C["cmd/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 都是安全的。

flowchart TB R["一次请求"] --> Q{"请求长什么样?"} Q -->|"要求 JSON Schema"| J["编一个符合 schema 的 JSON"] Q -->|"带了工具"| T["先 list_files,再 read_file,
然后总结"] 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 没有实现这个功能,但你可以算一算它要多少代码:

  1. 退出时,json.Marshal(a.Messages),写进 .mini-agent/session.json;
  2. 启动时加一个 -resume 参数,读回这个文件,json.Unmarshal 进 a.Messages;
  3. 恢复之后,第一句话照常 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")
}

拼出来是四段:

flowchart TB B["基础 prompt
你是谁、怎么工作、什么风格"] --> 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 篇的实验:

  1. 先说"我叫张三",再问"我叫什么"。它记得,因为历史每次都发过去了;
  2. 输入 /reset,再问"我叫什么"。它不知道了,因为历史清空了;
  3. 问"今天几号"。它知道,因为 system prompt 里写着。

在它回答到一半的时候按一下 Ctrl+C,看看程序是不是只打断了这一轮,然后你还能接着问。

实验 3:把历史打出来看看

在 mini-agent 里和它聊几轮之后,想看看它此刻的 Messages 到底长什么样,最直接的办法是临时在 REPL 里加一个 /dump 命令:json.MarshalIndent(a.Messages, "", " "),打印出来。对照 3.1 节的数据结构,看看每条消息的角色和内容块;如果接了 DeepSeek,还能看到 assistant 消息里那个 thinking 块。这也是 3.5 节存档功能的第一步:能打印出来,就能存下来。

实验 4:读代码

按这个顺序读,每个文件都不长:

  1. internal/llm/client.go:Model 接口和 Client;
  2. agent/agent.go:UI 接口、Agent 结构体、Send、appendUser;
  3. agent/prompt.go:system prompt 的拼装;
  4. cmd/mini-agent/main.go:terminalUI 和最后的 REPL;
  5. 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,但已经可以不花一分钱地测试了。

flowchart LR U["你"] --> R["REPL
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

评论

评论已关闭
Enter 搜索Esc 关闭