从零到一手撸 Agent 系列 --- 第 2 篇:和 LLM 对话 --- Provider 抽象层
系列:从零到一手撸 Agent 系列
难度:🟢 入门
对应源码:internal/provider/
开篇:你的 Agent 现在还不会"思考"
上一篇我们搭好了 CLI 骨架------三个子命令、flag 管理、.env 加载一应俱全。但它还是个空壳:coding-agent once -m "hello" 只是打了个招呼就退出了。
本篇我们要给它装上"大脑"------接入大语言模型(LLM)。从这一篇开始,你的 Agent 将真正能理解输入、产生输出。我们先聚焦在 Provider 层:它是一层抽象,把 OpenAI、Anthropic、DeepSeek 等不同 LLM 服务的差异封装起来,让上层(Agent 核心)不关心到底在调谁。
一、理解 LLM 的 Chat Completions API
不管用哪个厂商的 LLM,核心交互模式都一样------多轮对话:
yaml
你发送: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 根据上下文逐条推理并返回下一条消息。
有两个关键细节:
- 流式(Streaming) :LLM 不是一次性生成完整回复,而是一个字一个字"蹦"出来的。这需要解析 SSE(Server-Sent Events) 协议------HTTP 长连接,服务端不断推送
data:行。 - Tool Calling :LLM 可以在回复里说"我不直接回答,我建议你调用某个工具"。这以
tool_calls数组的形式出现,包含工具名和参数 JSON。
二、设计 Provider 层:用策略模式屏蔽差异
问题来了:OpenAI 和 Anthropic 的 API 格式完全不同(下文会详述),但上层 Agent 不想关心这些。怎么办?
答案:策略模式------定义统一接口,每个后端各自实现。
go
// Provider 是所有 LLM 后端的统一接口
type Provider interface {
Name() string
Stream(ctx context.Context, req Request) (<-chan Chunk, error)
}
就这么简单,只有两个方法:
Name():返回 "openai" 或 "anthropic",用于日志和错误提示Stream():发出请求,返回一个只读 channel,流式推送增量数据
然后,用一个全局注册表管理所有 Provider 实现:
go
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 惯用的插件模式:
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() 就会自动执行,全局注册表就填好了。上层只需:
go
prov, err := provider.New("openai", cfg) // 按名字取
新增 Provider 不需要改动任何现有代码 ------新写一个包、实现 Provider 接口、init() 里注册,搞定。
三、统一类型体系:不同后端的"共同语言"
在注册表和接口之下,是一套跨后端的类型系统:
3.1 消息(Message)
go
type Role string
const (
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 关联起来的桥梁。
3.2 流式增量(Chunk)
LLM 流式返回的不是一条完整消息,而是一个个片段。我们用枚举分类:
go
type ChunkType int
const (
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 分发。
3.3 请求与用量
go
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"------三件套,语义清晰。
四、收集流:CollectWithText
从 <-chan Chunk 里读增量很繁琐,所以提供了一个收集器:
go
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 Provider 实现:SSE 流解析
OpenAI 的 Chat Completions API 是事实标准,很多国产模型(DeepSeek、MiniMax、Moonshot 等)都兼容此协议。
5.1 请求格式
json
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}
}
5.2 响应格式(SSE 流)
text
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] 标志流结束。
5.3 解析逻辑
go
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)
}
}
5.4 兼容性细节
代码里有几个专门为 DeepSeek 等兼容 API 做的处理:
- DeepSeek 要求
assistant消息 content 不能为空 :当它是纯 tool_call 时,补一个" "(空格) - DeepSeek 要求
tool消息 content 不能为空:同上 - DeepSeek 在每个 delta 帧都重复发送 tool_call ID :用
findToolCallByID按 ID 去重
这些都是实战踩坑后补上的------写 LLM 应用最花时间的往往不是主线逻辑,而是各种 API 的边界情况。
六、Anthropic Provider 实现:完全不同的协议
Anthropic 的 Messages API 和 OpenAI 差异很大,这里只列关键不同点:
身份认证
go
// 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")
System 消息结构
Anthropic 的 system 消息不在 messages 数组里,而是顶级独立字段:
json
{
"model": "claude-sonnet-4-20250514",
"system": "你是一个编码助手", // ← 独立字段
"messages": [
{"role": "user", "content": "..."}
],
"max_tokens": 4096, // ← 必填!
"stream": true
}
消息内容:从纯文本到 Content Block
OpenAI 消息的 content 是纯字符串。Anthropic 是 content block 数组,每个 block 有类型:
json
// 文本 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 main\n..."}]
}
所以在构建请求时,需要把统一 Message 转换成 Anthropic 的 content block 格式------这是 buildRequestBody 的核心工作。
SSE 事件类型完全不同
| 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 打交道,出错是常态。我们定义了三种错误类型:
AuthError --- 认证失败(401/403)
go
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 无效或已过期"
}
对用户友好的关键:精确告诉用户该检查哪个环境变量。
APIError --- 其他 HTTP 错误(4xx/5xx)
StreamInterruptedError --- 流中断
当 LLM 已经输出了一部分内容后连接断开,这是一个特殊场景:不能直接重试(会重复输出),应该让上层(Agent)注入恢复 prompt。
go
func IsStreamInterrupted(err error) bool {
var interrupted *StreamInterruptedError
return errors.As(err, &interrupted)
}
IsPromptTooLong --- 上下文溢出
通过关键词匹配识别(不区分大小写):
go
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 的溢出报错格式各异,关键词匹配是最稳健的方案。
八、重试机制:SendWithRetry
网络抖动、服务端临时过载------这些问题不该直接抛给用户。我们用指数退避重试:
go
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
}
}
重试策略要点:
- 最多 10 次重试,指数退避 500ms × 2ⁿ + 随机 jitter(避免惊群效应)
- 如果服务端返回
Retry-After头,优先使用 - 401/403 如果曾经认证成功过,也重试最多 2 次(处理 token 临时失效)
- Context 取消:立即中止,不浪费时间
九、消息历史修复:SanitizeToolPairing
一个容易被忽略但非常重要的问题:对话历史中 assistant 的 tool_call 和 tool 的 tool_result 必须配对。如果出现"孤儿 tool 消息"(tood result 找不到对应 tool call),API 会直接返回 400 错误。
这个函数在每次发起请求前自动修复:
css
修复前:
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 命令
现在我们把第一篇的占位 once 命令接上真正的 Provider:
go
// 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
}
编译运行:
bash
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 将真正拥有"主动干活"的能力。
关联源码 :internal/provider/provider.go · types.go · retry.go · errors.go · openai/openai.go · anthropic/anthropic.go
🐙 本教程所有源码均来自开源项目 github.com/wsx864321/c...,欢迎 Star ⭐ 支持!你的每一个 Star 都是持续更新的动力~