练习 3:多轮对话——messages 数组 + for 循环

练习 1 的加分练习 4 你已经撞过一次墙:先说"我叫小明",再问"我叫什么", 它不知道。原因当时就讲了——这个 API 没有"会话",服务端不记得你是谁, 每次请求都要把完整历史带上

这一章就是把那句话变成代码。你会发现所谓"多轮对话", 全部机制就是一个数组和一个 for 循环。

敲进去

新建目录 ex03go mod init ex03,新建 main.go。 代码开始变长了,但没有新协议——send 函数就是练习 2 的代码原样搬进来, 只改了一处:流式打印的同时把完整回复攒下来返回。新东西全在 main 里:

// Learn Agent the Hard Way — 练习 3:多轮对话
//
// API 没有"会话"。所谓对话,是你维护的一个数组 + 一个 for 循环。
// 这一章练习 1 埋的伏笔全部收回。
package main

import (
	"bufio"
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"strings"
)

type request struct {
	Model         string         `json:"model"`
	Messages      []message      `json:"messages"`
	MaxTokens     int            `json:"max_tokens,omitempty"`
	Stream        bool           `json:"stream"`
	StreamOptions *streamOptions `json:"stream_options,omitempty"`
}

type streamOptions struct {
	IncludeUsage bool `json:"include_usage"`
}

type message struct {
	Role    string `json:"role"` // 今天集齐三种:"system" / "user" / "assistant"
	Content string `json:"content"`
}

type chunk struct {
	Choices []struct {
		Delta struct {
			Content string `json:"content"`
		} `json:"delta"`
		FinishReason string `json:"finish_reason"`
	} `json:"choices"`
	Usage *struct {
		PromptTokens     int `json:"prompt_tokens"`
		CompletionTokens int `json:"completion_tokens"`
	} `json:"usage"`
	Error *struct {
		Message string `json:"message"`
		Type    string `json:"type"`
	} `json:"error"`
}

func main() {
	apiKey := os.Getenv("OPENAI_API_KEY")
	model := os.Getenv("MODEL")
	if apiKey == "" || model == "" {
		fmt.Fprintln(os.Stderr, "需要环境变量 OPENAI_API_KEY 和 MODEL")
		fmt.Fprintln(os.Stderr, `例: export OPENAI_API_KEY=sk-xxxx`)
		fmt.Fprintln(os.Stderr, `    export MODEL=deepseek-v4-flash`)
		fmt.Fprintln(os.Stderr, `    export OPENAI_BASE_URL=https://api.deepseek.com/v1  # 不设则默认 OpenAI 官方`)
		os.Exit(1)
	}
	base := os.Getenv("OPENAI_BASE_URL")
	if base == "" {
		base = "https://api.openai.com/v1"
	}

	// 对话的全部状态就是这个数组。system 消息坐第 0 位,开场写一次,
	// 整场不动——它是给模型的"人设",每一轮都会跟着历史重新发出去。
	history := []message{
		{Role: "system", Content: "你是一个说话简洁的助手,回答不超过三句话。"},
	}

	fmt.Println(`输入你的话,回车发送;输入 exit 退出。`)
	stdin := bufio.NewScanner(os.Stdin)
	fmt.Print("> ")
	for stdin.Scan() {
		input := strings.TrimSpace(stdin.Text())
		if input == "" {
			fmt.Print("> ")
			continue
		}
		if input == "exit" {
			break
		}

		// 先把用户的话放进历史,再发送——发出去的快照必须包含它。
		history = append(history, message{Role: "user", Content: input})

		reply, ok := send(base, apiKey, model, history)
		if !ok {
			// 发送失败:把刚才 append 的那条弹回来。
			// 不弹的话,用户重试一次,历史里就有两条一样的话。
			history = history[:len(history)-1]
			fmt.Print("> ")
			continue
		}

		// 回复原样塞回历史——练习 1 说过:它和你发出去的消息是同一个形状。
		// 下一轮模型能"记得"自己说过什么,全靠这一行。
		history = append(history, message{Role: "assistant", Content: reply})
		fmt.Print("> ")
	}
}

// send 把整个 history 发出去,流式打印回复,返回攒好的完整文本。
// 打印是给人看的,攒是给下一轮用的——同一份字节,两个去处。
func send(base, apiKey, model string, history []message) (string, bool) {
	body, _ := json.Marshal(request{
		Model:         model,
		MaxTokens:     1024,
		Messages:      history,
		Stream:        true,
		StreamOptions: &streamOptions{IncludeUsage: true},
	})

	req, err := http.NewRequest("POST", base+"/chat/completions", bytes.NewReader(body))
	if err != nil {
		fmt.Fprintln(os.Stderr, err)
		return "", false
	}
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", "Bearer "+apiKey)
	req.Header.Set("Accept", "text/event-stream")

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		fmt.Fprintln(os.Stderr, "请求失败:", err)
		return "", false
	}
	defer resp.Body.Close()

	if resp.StatusCode != 200 {
		raw, _ := io.ReadAll(resp.Body)
		fmt.Fprintf(os.Stderr, "HTTP %d: %s\n", resp.StatusCode, raw)
		return "", false
	}

	var (
		full          strings.Builder // 攒完整回复,退出前塞回 history
		finish        string
		inTok, outTok int
	)
	scanner := bufio.NewScanner(resp.Body)
	for scanner.Scan() {
		line := scanner.Text()
		if !strings.HasPrefix(line, "data:") {
			continue
		}
		data := strings.TrimPrefix(strings.TrimPrefix(line, "data:"), " ")
		if data == "" {
			continue
		}
		if data == "[DONE]" {
			break
		}

		var c chunk
		if err := json.Unmarshal([]byte(data), &c); err != nil {
			fmt.Fprintf(os.Stderr, "\n解析失败: %v\n原始行: %s\n", err, data)
			return "", false
		}
		if c.Error != nil {
			fmt.Fprintf(os.Stderr, "\nAPI 错误 [%s]: %s\n", c.Error.Type, c.Error.Message)
			return "", false
		}
		if c.Usage != nil {
			inTok, outTok = c.Usage.PromptTokens, c.Usage.CompletionTokens
		}
		if len(c.Choices) == 0 {
			continue
		}
		if d := c.Choices[0].Delta.Content; d != "" {
			fmt.Print(d)
			full.WriteString(d)
		}
		if c.Choices[0].FinishReason != "" {
			finish = c.Choices[0].FinishReason
		}
	}
	if err := scanner.Err(); err != nil {
		fmt.Fprintln(os.Stderr, "\n读流失败:", err)
		return "", false
	}

	fmt.Printf("\n")
	fmt.Fprintf(os.Stderr, "[输入 %d tokens · 输出 %d tokens · finish_reason=%s]\n",
		inTok, outTok, finish)
	return full.String(), true
}

先别问为什么。敲完,跑起来,我们再回头讲。

跑起来

环境变量照旧,云端 DeepSeek 或本机 Ollama 任选:

go build -o ex03 . && ./ex03

进去之后,把练习 1 那个失败的实验重新做一遍。

你应该看到什么

这是我用 DeepSeek 跑的一场(本机 Ollama 的效果一样,token 数更小):

输入你的话,回车发送;输入 exit 退出。
> 我叫小明
你好,小明!有什么可以帮你?
[输入 97 tokens · 输出 30 tokens · finish_reason=stop]
> 我叫什么?
你叫小明。
[输入 112 tokens · 输出 101 tokens · finish_reason=stop]
> exit

它记得了。 练习 1 的失忆,两行 append 治好了。

再看两个数字。第一,输入 token 从 97 涨到了 112——第二轮发出去的不只是 "我叫什么?"五个字,是 system + 第一轮的一问一答 + 新问题,全套。 第二,"你叫小明。"四个字,输出怎么会是 101 个 token?记住这个疑点,马上讲。

发生了什么

对话是幻觉,幻觉的维护者是你。 服务端处理完一个请求就把你忘干净了。 "模型记得你叫小明"的真相是:你把"我叫小明"原文再发了一遍,它现场重新读了一次。 每一轮都是全量重发、现场重读——for 循环里那两行 append, 就是全世界所有 AI 对话产品"记忆力"的全部实现。

三种 role 到齐了。 system 坐在数组第 0 位,开场写一次,整场不动。 它不是发一次就"生效"了——它每一轮都跟着历史重新出发, 所以模型每一轮都重新被提醒自己是谁(加分练习 3 你会亲眼看到这有多顽固)。 userassistant 轮流往后排。第四种 role 是 tool,练习 5 见。

先 append,再发送;失败了,弹回来。 顺序不能反:发出去的快照必须包含 用户刚说的话。而失败时那行 history = history[:len(history)-1] 看着多余, 其实在防一类最难查的 bug——历史被污染。少了它,一次网络抖动加一次用户重试, 历史里就有两条一样的话;模型看到的对话和用户以为的对话,从此开始漂移。 历史数组是你的 harness 里第一份需要守护完整性的状态。

101 个 token 的谜底:deepseek-v4-flash 也是思考型模型。 把这轮请求用非流式重发一遍,看原始 JSON 的 usage:

"completion_tokens": 116,
"completion_tokens_details": { "reasoning_tokens": 111 }

说出口的是四个字,暗地里想了 111 个 token——思考默认不显示,但计费。 练习 1 里 qwen3:4b 把 1024 预算全想光,是失控的思考; 这里是自律的思考——但钱照收。从第一天就盯着 token 数的习惯, 今天第一次抓到东西。

最后退一步看这个 for 循环。读一行 → 塞进历史 → 发给模型 → 回复塞回历史。 练习 5 的 agent loop 只是给它加一种分支:回复里如果是工具调用, 就执行工具、把结果塞回历史、再发一次。循环不变,变的是历史里流动的东西—— 现在是人和模型的对话,很快是模型和工具之间的调用与汇报。

常见问题

  • 它还是不记得:两行 append 缺了哪行?只 append 用户的话不 append 回复, 或者反过来,模型看到的历史都是缺页的。
  • 聊得越久,每轮越慢、越贵:不是错觉,是必然——历史全量重发, 输入 token 单调增长。这本书用一整个 Part 3 来对付它。
  • 输出 token 远多于看到的字数:思考型模型在暗处想(见上文)。 用非流式请求打印完整 usage,reasoning_tokens 会告诉你钱花哪了。
  • 想退出exit,或者 Ctrl+D(EOF 会让 stdin.Scan() 返回 false)。

加分练习

  1. 把某一轮的请求用 curl 非流式重发,看完整的 usage JSON。DeepSeek 会给你 completion_tokens_details.reasoning_tokens 和藏在 message 里的 reasoning_content——它每一轮都在想,只是不给你看。
  2. history 改成每轮只发 {system, 当前输入} 两条,重跑 "我叫小明 / 我叫什么"。恭喜,你亲手造出了练习 1 的失忆机—— 现在你对"上下文"三个字有了肌肉记忆。
  3. 把 system 换成"无论用户用什么语言提问,你都只用英文回答", 然后用中文连聊几轮。它每一轮都坚持英文——因为 system 每一轮都重新出发。 再把 system 从数组里删掉试试。
  4. 打印每轮的 len(history),配着 stderr 的输入 token 数聊十轮, 感受增长曲线。然后想:照这个速度,多少轮撞上模型的上下文窗口上限? 撞上了该扔谁、留谁?别急着答——练习 12 和 13 就是这道题。