从零到一手撸 Agent 系列 — 第 2 篇:和 LLM 对话 — Provider 抽象层

从零到一手撸 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 根据上下文逐条推理并返回下一条消息。

有两个关键细节:

  1. 流式(Streaming) :LLM 不是一次性生成完整回复,而是一个字一个字"蹦"出来的。这需要解析 SSE(Server-Sent Events) 协议------HTTP 长连接,服务端不断推送 data: 行。
  2. 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 消息关联的工具名
}

ContentToolCalls 是互斥的------一条 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_startcontent_block_startcontent_block_deltamessage_deltamessage_stoperror
工具调用在 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
统一类型体系:MessageChunkToolCallRequest types.go
OpenAI SSE 流式解析 openai/openai.goreadStream
Anthropic 的 content block 格式及与 OpenAI 的 5 个关键差异 anthropic/anthropic.go
错误分类:AuthError / APIError / StreamInterrupted / PromptTooLong errors.go
指数退避重试 + Retry-After + jitter retry.go
流式收集器:CollectWithText + onText 实时回调 provider.goCollectWithText
消息历史修复: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 都是持续更新的动力~

相关推荐
该用户已不存在2 小时前
英伟达开放 GLM-5.2 API 端点,免费的!一篇文章告诉你怎么零成本接入
ai编程·nvidia·chatglm (智谱)
阿里云云原生2 小时前
从 ReAct 到 Harness:AgentScope 2.0 如何用“工程化”定义分布式智能体开发范式?
云原生·agent
思考着亮3 小时前
4.Message与提示词模板2
agent
木西4 小时前
自动化工程实践:基于 DeepSeek 与 Hardhat 的 Web3 技术长文生成 Skill 实现
openai·agent·deepseek
赵大仁4 小时前
Cursor vs Claude Code vs Copilot:2026 年 AI 编程工具怎么选
人工智能·ai编程·cursor·github copilot·claude code
牧艺4 小时前
从 Chat 到 Agent:Tool Calling 全栈工程化实践
agent·ai编程·全栈
AI编程实验室4 小时前
AI 写完了功能,谁来为凌晨两点的报警负责?
ai编程
码哥字节5 小时前
一个火箭公司砸600亿买编程工具,图什么?
ai编程
硬核子牙6 小时前
代码展示大模型的智能
linux·chatgpt·agent