练习 1:一次 API 调用
你要写的这个东西,最后会长成一个 harness——会改文件、会跑命令、会记住事情、会派出分身。 但它的心脏只是一个 HTTP 请求。今天你就写这个请求,别的什么都不写。
不要复制粘贴。敲进去。 敲的过程就是你第一次读懂这个协议。
敲进去
新建目录 ex01,在里面跑 go mod init ex01,然后新建 main.go:
// Learn Harness the Hard Way — 练习 1:一次 API 调用
//
// 你的 agent 的一切,都从这一个 HTTP 请求开始。
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
)
// request 是 POST /chat/completions 请求体的最小形状。
// 完整协议还有很多字段(tools、stream、reasoning_effort……),后面的练习会逐个长出来。
type request struct {
Model string `json:"model"`
Messages []message `json:"messages"`
MaxTokens int `json:"max_tokens,omitempty"`
}
// message 是对话里的一条消息。
// 到练习 5,assistant 的消息里会多出 tool_calls 字段——那是模型伸手干活的地方。
type message struct {
Role string `json:"role"` // "system" / "user" / "assistant"
Content string `json:"content"`
}
// response 只解析我们需要的字段——JSON 里多余的字段会被忽略,这是协议演进的余地。
type response struct {
Choices []struct {
Message message `json:"message"`
FinishReason string `json:"finish_reason"` // "stop" | "length" | "tool_calls" …
} `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"` // 出错时 API 返回的是这个形状,成功时它是 null
}
func main() {
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, `用法: ./ex01 "你的问题"`)
os.Exit(1)
}
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"
}
body, _ := json.Marshal(request{
Model: model,
MaxTokens: 1024,
Messages: []message{{Role: "user", Content: os.Args[1]}},
})
req, err := http.NewRequest("POST", base+"/chat/completions", bytes.NewReader(body))
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
// 两个头,一个都不能少:
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+apiKey) // 认证
resp, err := http.DefaultClient.Do(req)
if err != nil {
fmt.Fprintln(os.Stderr, "请求失败:", err)
os.Exit(1)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
var r response
if err := json.Unmarshal(raw, &r); err != nil {
fmt.Fprintf(os.Stderr, "解析失败: %v\n原始响应: %s\n", err, raw)
os.Exit(1)
}
if r.Error != nil {
fmt.Fprintf(os.Stderr, "API 错误 [%s]: %s\n", r.Error.Type, r.Error.Message)
os.Exit(1)
}
if len(r.Choices) == 0 {
fmt.Fprintf(os.Stderr, "空响应: %s\n", raw)
os.Exit(1)
}
fmt.Println(r.Choices[0].Message.Content)
fmt.Fprintf(os.Stderr, "\n[输入 %d tokens · 输出 %d tokens · finish_reason=%s]\n",
r.Usage.PromptTokens, r.Usage.CompletionTokens, r.Choices[0].FinishReason)
}
先别问为什么。敲完,跑起来,我们再回头讲。
跑起来
你需要一个模型服务商的 key。任何一家都行——OpenAI 协议是这个行业的通用语, DeepSeek、Kimi、通义、OpenAI 官方、甚至你本机跑的 Ollama,都说这门话:
走云端,DeepSeek 是国内最省事的选择:
export OPENAI_BASE_URL=https://api.deepseek.com/v1
export OPENAI_API_KEY=sk-你的key
export MODEL=deepseek-v4-flash
走本机,用练习 0 装好的 Ollama(key 随便填,它不校验,但字段不能空—— 你的代码里检查了它):
export OPENAI_BASE_URL=http://localhost:11434/v1
export OPENAI_API_KEY=ollama
export MODEL=qwen3:4b-instruct
之后每个练习都同时支持这两种跑法,选一种,或者都跑——对比它们的输出本身就有教育意义。
go build -o ex01 . && ./ex01 "用一句话说明什么是 harness"
你应该看到什么
Harness(测试夹具/测试框架)是用于自动驱动被测系统、执行测试并验证结果的
工具和代码集合,通常包括测试运行器、桩模块和断言库。
[输入 88 tokens · 输出 218 tokens · finish_reason=stop]
具体文字每次都不一样。注意最后那行:从第一天起就盯着 token 数和 finish_reason, 这本书后面一半的设计决定,都是在跟这两样东西搏斗。
(顺便,DeepSeek 把 harness 解释成了"测试夹具",本机的 qwen3:4b-instruct 说是"缚具"。 都错了,而且错得各不相同——你正在跟着写的这本书就是纠正它们的。 模型不知道的事情比你想的多,这也是你需要 harness 的原因之一。)
发生了什么
拆开看,你刚才发出去的东西只有三层:
两个请求头。
Authorization: Bearer <key> 是认证,Content-Type: application/json 不用解释。
就这么多——这个协议的门槛低到只有一把钥匙。
请求体三个字段。
model 是你要哪个模型;max_tokens 是输出上限——硬顶,模型说到一半也会被掐断;
messages 是对话本身,一个数组,现在只有一条 {"role": "user", "content": "..."}。
盯着 messages 多看一眼。它是个数组,而且每条消息带 role。
这个 API 没有"会话"的概念,服务端不记得你是谁、上一句说了什么——
每次请求都要把完整历史带上。"对话"是客户端的幻觉,
是你(harness 的作者)负责维护的。练习 3 我们就靠这一点做出多轮对话。
role 现在只用到 user,很快你会见到 system、assistant,
到练习 5 还会见到第四种——tool。四种角色就是整个 agent 世界的人物表。
响应里现在只看三样。
choices[0].message 是模型的回话——注意它和你发出去的消息是同一个形状,
这不是巧合:下一轮对话你要把它原样塞回 messages 数组里。这个对象将来还会长出一个
tool_calls 字段——模型要动手干活的时候,就从那里伸手。今天的代码里已经埋着练习 5 的地基。
finish_reason 告诉你它为什么停:stop 是说完了,length 是被 max_tokens 掐断了,
tool_calls 是它想调工具(练习 5 见)。判断"说完了没有"永远看这个字段,别看文字像不像结尾。
usage 是账单。error 在成功时是 null——出错时 API 返回的是另一个形状的 JSON,
我们把两个形状合在一个结构体里解析,这是 Go 处理这类协议最省事的写法。
就这些。没有 SDK、没有框架、没有魔法。你的 harness 和所有模型服务商之间, 只隔着这一个 POST。
常见问题
- 401:key 不对,或者
Bearer和 key 之间少了空格。 - 404 model not found:模型名各家写法不同,用你服务商自己的模型列表,别照抄别家文档。
- 404 或奇怪的 HTML:
OPENAI_BASE_URL的路径不对——有的服务商要带/v1,有的自带。 代码里拼的是base + "/chat/completions",自己对一下最终 URL。 - 回答是空的,但
finish_reason=length:你用了思考型模型(比如 Ollama 的qwen3:4b——注意不是我们用的qwen3:4b-instruct)。思考也计入 max_tokens, 1024 个预算全被它想掉了,一个字都没轮到说;思考内容躲在响应里一个叫reasoning的字段里,我们的结构体没解析它。你刚学的"判断说完了没有,看 finish_reason 别看文字" 在这里第一次派上用场——文字是空的,但length告诉你它不是没话说,是被掐断了。
加分练习
- 把
max_tokens改成10,跑一次。回答被掐断了,但程序没报错—— 看finish_reason,它是length。以后你的 harness 每收到一个响应都要先看这里。 - 把整个原始响应
raw打印出来,数一数有多少字段是我们没解析的。协议比你用到的大得多—— 只解析需要的字段,是客户端活得久的方式。 - 换一家服务商再跑(Kimi、通义、本机 Ollama),一行代码都不用改。 这就是"通用协议"的分量——练习 4 我们接 Anthropic 协议做对照,你会看到不通用是什么体验。
- 连续跑两次:
./ex01 "我叫小明",然后./ex01 "我叫什么"。它不知道你叫什么。 想想为什么——你已经知道答案了(提示:messages 是个数组)。练习 3 修好它。