为纪念 35 周年:《Terminator 2: Judgment Day》将在全球影院重映
2026-07-13
2026-07-18 0
上一篇我们搭好了 CLI 骨架——三个子命令、flag 管理、.env 加载一应俱全。但它还是个空壳:coding-agent once -m "hello" 只是打了个招呼就退出了。

本篇我们要给它装上"大脑"——接入大语言模型(LLM)。从这一篇开始,你的 Agent 将真正能理解输入、产生输出。我们先聚焦在 Provider 层:它是一层抽象,把 OpenAI、Anthropic、DeepSeek 等不同 LLM 服务的差异封装起来,让上层(Agent 核心)不关心到底在调谁。
不管用哪个厂商的 LLM,核心交互模式都一样——多轮对话:
复制代码你发送:Messages = [
{role: "system", content: "你是一个编码助手..."}, ← 系统人设
{role: "user", content: "帮我读一下 main.go"}, ← 用户输入
{role: "assistant", content: null, tool_calls: [...]}, ← LLM 说要调工具
{role: "tool", content: "文件内容是...", ...}, ← 工具执行结果
...
]
LLM 返回:下一条 assistant 消息(可能是文本,也可能是 tool_calls)
这就是 Chat Completions 的核心:一个有序的消息列表,LLM 根据上下文逐条推理并返回下一条消息。
有两个关键细节:
data: 行。tool_calls 数组的形式出现,包含工具名和参数 JSON。问题来了:OpenAI 和 Anthropic 的 API 格式完全不同(下文会详述),但上层 Agent 不想关心这些。怎么办?
答案:策略模式——定义统一接口,每个后端各自实现。
复制代码// Provider 是所有 LLM 后端的统一接口
type Provider interface {
Name() string
Stream(ctx context.Context, req Request) (<-chan Chunk, error)
}
就这么简单,只有两个方法:
Name():返回 "openai" 或 "anthropic",用于日志和错误提示Stream():发出请求,返回一个只读 channel,流式推送增量数据然后,用一个全局注册表管理所有 Provider 实现:
复制代码var registry = map[string]Factory{} // "openai" → 构造OpenAI的函数type Factory func(cfg Config) (Provider, error)func Register(kind string, f Factory) { registry[kind] = f }
func New(kind string, cfg Config) (Provider, error) { ... }
每个 Provider 在自己的 init() 中自我注册——这是 Go 惯用的插件模式:
复制代码// internal/provider/openai/openai.go
func init() {
provider.Register("openai", func(cfg provider.Config) (provider.Provider, error) {
return New(cfg)
})
}// internal/provider/anthropic/anthropic.go
func init() {
provider.Register("anthropic", func(cfg provider.Config) (provider.Provider, error) {
return New(cfg)
})
}
只要在 main.go 里用 import _ "..." 引入这两个包,init() 就会自动执行,全局注册表就填好了。上层只需:
复制代码prov, err := provider.New("openai", cfg) // 按名字取
新增 Provider 不需要改动任何现有代码——新写一个包、实现 Provider 接口、init() 里注册,搞定。
在注册表和接口之下,是一套跨后端的类型系统:
复制代码type Role stringconst (
RoleSystem Role = "system" // 系统提示
RoleUser Role = "user" // 用户输入
RoleAssistant Role = "assistant" // LLM 回复
RoleTool Role = "tool" // 工具执行结果
)type Message struct {
Role Role `json:"role"`
Content string `json:"content"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"` // assistant 提出的工具调用
ToolCallID string `json:"tool_call_id,omitempty"` // tool 消息关联的调用 ID
Name string `json:"name,omitempty"` // tool 消息关联的工具名
}
Content 和 ToolCalls 是互斥的——一条 assistant 消息要么是纯文本,要么是工具调用请求。ToolCallID 则是把 tool result 和之前的 tool call 关联起来的桥梁。
LLM 流式返回的不是一条完整消息,而是一个个片段。我们用枚举分类:
复制代码type ChunkType intconst (
ChunkText ChunkType = iota // 文本增量:"你" → "好" → "!"
ChunkToolCallStart // 工具调用开始:携带 ID + 工具名
ChunkToolCallDelta // 工具参数增量:{"fi → le": → "ma...
ChunkUsage // token 用量统计
ChunkDone // 流结束信号
ChunkError // 流中错误
)type Chunk struct {
Type ChunkType
Text string // ChunkText 的文本内容
ToolCall *ToolCall // ChunkToolCallStart/Delta 的工具调用数据
Usage *Usage // ChunkUsage 的统计信息
Err error // ChunkError 的错误
}
每一种 ChunkType 对应 Chunk 结构体里不同的有效字段。上层用 switch chunk.Type 分发。
复制代码type Request struct {
Model string // 模型名
Messages []Message // 对话历史
Tools []ToolSchema // 可用工具列表(JSON Schema)
MaxTokens int
Temperature float32
}type Usage struct {
PromptTokens int
CompletionTokens int
TotalTokens int
FinishReason string // "stop" | "tool_calls" | "length"
}
Request 是"问"、Chunk 是"流式答"、Usage 是"花了多少 token"——三件套,语义清晰。
从 <-chan Chunk 里读增量很繁琐,所以提供了一个收集器:
复制代码func CollectWithText(ch <-chan Chunk, onText func(string)) (Message, *Usage, error) {
var text strings.Builder
var toolCalls []ToolCall
var usage *Usage
currentTCIndex := -1 for chunk := range ch {
switch chunk.Type {
case ChunkText:
text.WriteString(chunk.Text) // 拼接所有文本增量
if onText != nil { onText(chunk.Text) } // 边收边通知
case ChunkToolCallStart:
// 按 ID 去重(DeepSeek 等会在每个 delta 帧重复发送 ID)
if idx := findToolCallByID(toolCalls, chunk.ToolCall.ID); idx >= 0 {
toolCalls[idx].Arguments += chunk.ToolCall.Arguments
} else {
toolCalls = append(toolCalls, *chunk.ToolCall)
currentTCIndex = len(toolCalls) - 1
}
case ChunkToolCallDelta:
toolCalls[currentTCIndex].Arguments += chunk.ToolCall.Arguments
case ChunkUsage:
// 合并多次 usage 上报(Anthropic 分两次发)
mergeUsage(usage, chunk.Usage)
case ChunkError:
return Message{}, nil, chunk.Err
case ChunkDone:
// 流正常结束
}
}
return Message{Role: RoleAssistant, Content: text.String(), ToolCalls: toolCalls}, usage, nil
}
onText 回调让上层可以实时拿到每一个字——这正是下一篇文章中 Agent 主循环做流式终端输出的基础。
OpenAI 的 Chat Completions API 是事实标准,很多国产模型(DeepSeek、MiniMax、Moonshot 等)都兼容此协议。
复制代码POST https://api.openai.com/v1/chat/completions
Authorization: Bearer sk-xxx
Content-Type: application/json{
"model": "gpt-4o",
"messages": [
{"role": "system", "content": "你是一个编码助手"},
{"role": "user", "content": "帮我读 main.go"}
],
"tools": [
{"type": "function", "function": {"name": "read_file", "description": "...", "parameters": {...}}}
],
"stream": true,
"stream_options": {"include_usage": true}
}
复制代码data: {"choices":[{"delta":{"role":"assistant"},"index":0}]}data: {"choices":[{"delta":{"content":"好"},"index":0}]}data: {"choices":[{"delta":{"content":"的"},"index":0}]}...data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_abc","function":{"name":"read_file","arguments":""}}]},"index":0}]}data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{"path":"main.go"}"}}]},"index":0}]}data: {"choices":[{"delta":{},"finish_reason":"tool_calls","index":0}],"usage":{...}}data: [DONE]
每一行 data: {...} 是一个 JSON 增量。[DONE] 标志流结束。
复制代码func (c *client) readStream(ctx context.Context, body io.ReadCloser, ch chan<- provider.Chunk) {
defer close(ch)
defer body.Close() scanner := bufio.NewScanner(body)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) // 单行最大 1MB for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "data:") { continue }
data := strings.TrimPrefix(line, "data:")
if data == "[DONE]" {
ch <- provider.Chunk{Type: provider.ChunkDone}
return
}
// 解析 JSON delta,转为统一 Chunk 格式
processDeltaAndSend(data, ch)
}
}
代码里有几个专门为 DeepSeek 等兼容 API 做的处理:
assistant 消息 content 不能为空:当它是纯 tool_call 时,补一个 " "(空格)tool 消息 content 不能为空:同上findToolCallByID 按 ID 去重这些都是实战踩坑后补上的——写 LLM 应用最花时间的往往不是主线逻辑,而是各种 API 的边界情况。
Anthropic 的 Messages API 和 OpenAI 差异很大,这里只列关键不同点:
复制代码// OpenAI: Bearer token
httpReq.Header.Set("Authorization", "Bearer "+c.apiKey)// Anthropic: x-api-key
httpReq.Header.Set("x-api-key", c.apiKey)
httpReq.Header.Set("anthropic-version", "2023-06-01")
Anthropic 的 system 消息不在 messages 数组里,而是顶级独立字段:
复制代码{
"model": "claude-sonnet-4-20250514",
"system": "你是一个编码助手", // ← 独立字段
"messages": [
{"role": "user", "content": "..."}
],
"max_tokens": 4096, // ← 必填!
"stream": true
}
OpenAI 消息的 content 是纯字符串。Anthropic 是 content block 数组,每个 block 有类型:
复制代码// 文本 block
{"type": "text", "text": "我来读一下这个文件"}// 工具调用 block
{"type": "tool_use", "id": "toolu_001", "name": "read_file", "input": {"path": "main.go"}}// 工具结果 block(放在 user 消息里)
{
"role": "user",
"content": [{"type": "tool_result", "tool_use_id": "toolu_001", "content": "package mainn..."}]
}
所以在构建请求时,需要把统一 Message 转换成 Anthropic 的 content block 格式——这是 buildRequestBody 的核心工作。
| OpenAI SSE 特点 | Anthropic SSE 特点 |
|---|---|
每条 data: 都是 choices[0].delta | 六种事件类型:message_start、content_block_start、content_block_delta、message_delta、message_stop、error |
工具调用在 delta.tool_calls 里 | 工具调用跨两个事件:content_block_start(发 ID + name)+ content_block_delta(发参数 JSON) |
| Usage 在最后一条 delta 里 | Usage 分两次:message_start(input_tokens)+ message_delta(output_tokens) |
Finish reason:"stop" / "tool_calls" | Stop reason:"end_turn" / "tool_use" / "max_tokens" → 统一映射到 FinishReasonStop 等常量 |
这个映射工作全部封装在 processEvent 里,对外暴露的始终是统一的 provider.Chunk 类型——这就是策略模式的价值。
跟 LLM API 打交道,出错是常态。我们定义了三种错误类型:
复制代码type AuthError struct {
Provider string // 哪个 provider
KeyEnv string // API key 来源环境变量名
Status int // HTTP 状态码
HasKey bool // Key 是否已设置
}func (e *AuthError) Error() string {
if !e.HasKey {
return "未设置 API key(请检查环境变量 OPENAI_API_KEY)"
}
return "API key 无效或已过期"
}
对用户友好的关键:精确告诉用户该检查哪个环境变量。
当 LLM 已经输出了一部分内容后连接断开,这是一个特殊场景:不能直接重试(会重复输出),应该让上层(Agent)注入恢复 prompt。
复制代码func IsStreamInterrupted(err error) bool {
var interrupted *StreamInterruptedError
return errors.As(err, &interrupted)
}
通过关键词匹配识别(不区分大小写):
复制代码func IsPromptTooLong(err error) bool {
s := err.Error()
for _, kw := range []string{
"prompt_too_long", "context_length_exceeded",
"max_context_length", "too many tokens", ...
} {
if containsFold(s, kw) { return true }
}
return false
}
这很实用——不同 API 的溢出报错格式各异,关键词匹配是最稳健的方案。
网络抖动、服务端临时过载——这些问题不该直接抛给用户。我们用指数退避重试:
复制代码func SendWithRetry(ctx context.Context, httpClient *http.Client, opts SendOptions,
newReq func(context.Context) (*http.Request, error)) (*http.Response, error) { for attempt := 0; attempt <= MaxRetries; attempt++ {
if attempt > 0 {
delay := backoffDelay(attempt, retryAfter)
time.Sleep(delay) // 500ms → 1s → 2s → 4s ... 上限 15s
}
resp, err := httpClient.Do(req)
if resp.StatusCode == 200 { return resp, nil } // 4xx(非 401/403)不重试,立即返回
if !RetryableStatus(resp.StatusCode) { return nil, apiErr } // 429(限流)或 5xx:退避重试
lastErr = apiErr
}
}
重试策略要点:
Retry-After 头,优先使用一个容易被忽略但非常重要的问题:对话历史中 assistant 的 tool_call 和 tool 的 tool_result 必须配对。如果出现"孤儿 tool 消息"(tood result 找不到对应 tool call),API 会直接返回 400 错误。
这个函数在每次发起请求前自动修复:
复制代码修复前:
assistant: "我建议读文件" tool_calls=[{id:1, name: read_file}]
tool: {id:2, result: "..."} ← 孤儿消息!id=2 没有对应的 tool_call修复后:
assistant: "我建议读文件" tool_calls=[{id:1, name: read_file}]
assistant: " " tool_calls=[{id:2, name: unknown_tool}] ← 补充占位
tool: {id:2, result: "..."}
现在我们把第一篇的占位 once 命令接上真正的 Provider:
复制代码// cmd/cli/once.go 的 runOnce 改造func runOnce(cmd *cobra.Command, args []string) error {
cfg := buildConfig(cmd) // 从 flag 构建 agent.Config // 创建 Provider
prov, err := provider.New(cfg.ProviderKind, provider.Config{
Name: cfg.ProviderKind,
BaseURL: cfg.BaseURL,
Model: cfg.Model,
APIKey: cfg.APIKey,
})
if err != nil { return err } // 构造请求:system + user 两条消息,不带工具
messages := []provider.Message{
{Role: provider.RoleSystem, Content: "你是一个编码助手,用中文回答。"},
{Role: provider.RoleUser, Content: onceMessage},
} // 发起流式请求
ch, err := prov.Stream(cmd.Context(), provider.Request{
Model: cfg.Model,
Messages: messages,
})
if err != nil { return err } // 实时打印流式输出
msg, _, err := provider.CollectWithText(ch, func(s string) {
fmt.Print(s) // 每收到一个字的增量就立即打印
})
fmt.Println()
return err
}
编译运行:
复制代码go build -o coding-agent ./cmd
./coding-agent once -m "用 Go 写一个 Hello World"
你会看到 LLM 逐字输出代码——这是你的 Agent 第一次真正"说话"!虽然它还没有工具能力(那是下一篇的事),但它已经能理解你的意图并生成回答了。
这篇我们实现了 Provider 抽象层,这是整个 Agent 的"语言中枢"。
| 你学到了什么 | 对应代码 |
|---|---|
策略模式:Provider 接口 + Register/New 注册表 | provider.go |
统一类型体系:Message、Chunk、ToolCall、Request | types.go |
| OpenAI SSE 流式解析 | openai/openai.go 的 readStream |
| Anthropic 的 content block 格式及与 OpenAI 的 5 个关键差异 | anthropic/anthropic.go |
| 错误分类:AuthError / APIError / StreamInterrupted / PromptTooLong | errors.go |
| 指数退避重试 + Retry-After + jitter | retry.go |
流式收集器:CollectWithText + onText 实时回调 | provider.go 的 CollectWithText |
消息历史修复:SanitizeToolPairing | provider.go |
下篇预告:我们将进入整个系列最激动人心的部分——Agent 主循环。你会看到 Agent 如何:发起 LLM 请求 → 收到 tool_call → 执行工具 → 结果回传 → 再次请求 → …直到 LLM 给出最终回答。届时你的 Agent 将真正拥有"主动干活"的能力。