练习 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,很快你会见到 systemassistant, 到练习 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 或奇怪的 HTMLOPENAI_BASE_URL 的路径不对——有的服务商要带 /v1,有的自带。 代码里拼的是 base + "/chat/completions",自己对一下最终 URL。
  • 回答是空的,但 finish_reason=length:你用了思考型模型(比如 Ollama 的 qwen3:4b ——注意不是我们用的 qwen3:4b-instruct)。思考也计入 max_tokens, 1024 个预算全被它想掉了,一个字都没轮到说;思考内容躲在响应里一个叫 reasoning 的字段里,我们的结构体没解析它。你刚学的"判断说完了没有,看 finish_reason 别看文字" 在这里第一次派上用场——文字是空的,但 length 告诉你它不是没话说,是被掐断了。

加分练习

  1. max_tokens 改成 10,跑一次。回答被掐断了,但程序没报错—— 看 finish_reason,它是 length。以后你的 harness 每收到一个响应都要先看这里。
  2. 把整个原始响应 raw 打印出来,数一数有多少字段是我们没解析的。协议比你用到的大得多—— 只解析需要的字段,是客户端活得久的方式。
  3. 换一家服务商再跑(Kimi、通义、本机 Ollama),一行代码都不用改。 这就是"通用协议"的分量——练习 4 我们接 Anthropic 协议做对照,你会看到不通用是什么体验。
  4. 连续跑两次:./ex01 "我叫小明",然后 ./ex01 "我叫什么"。它不知道你叫什么。 想想为什么——你已经知道答案了(提示:messages 是个数组)。练习 3 修好它。