练习 3:多轮对话——messages 数组 + for 循环
练习 1 的加分练习 4 你已经撞过一次墙:先说"我叫小明",再问"我叫什么", 它不知道。原因当时就讲了——这个 API 没有"会话",服务端不记得你是谁, 每次请求都要把完整历史带上。
这一章就是把那句话变成代码。你会发现所谓"多轮对话", 全部机制就是一个数组和一个 for 循环。
敲进去
新建目录 ex03,go 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 你会亲眼看到这有多顽固)。
user 和 assistant 轮流往后排。第四种 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)。
加分练习
- 把某一轮的请求用 curl 非流式重发,看完整的 usage JSON。DeepSeek 会给你
completion_tokens_details.reasoning_tokens和藏在 message 里的reasoning_content——它每一轮都在想,只是不给你看。 - 把
history改成每轮只发{system, 当前输入}两条,重跑 "我叫小明 / 我叫什么"。恭喜,你亲手造出了练习 1 的失忆机—— 现在你对"上下文"三个字有了肌肉记忆。 - 把 system 换成"无论用户用什么语言提问,你都只用英文回答", 然后用中文连聊几轮。它每一轮都坚持英文——因为 system 每一轮都重新出发。 再把 system 从数组里删掉试试。
- 打印每轮的
len(history),配着 stderr 的输入 token 数聊十轮, 感受增长曲线。然后想:照这个速度,多少轮撞上模型的上下文窗口上限? 撞上了该扔谁、留谁?别急着答——练习 12 和 13 就是这道题。