跳至内容
M02 LLM API 集成与提示工程

M02 LLM API 集成与提示工程

在 Agent 系统中,LLM 调用层是后续所有能力的基础。

工具调用、记忆系统、RAG、多智能体协作,这些能力表面上看起来各不相同,但它们最终都要落到一件事上:如何稳定、可控地调用模型。

因此,这一模块不只是介绍“如何请求一个模型接口”,而是要在 Go 项目中搭建一层可扩展的 LLM 接入能力。

这一章的重点不是“会调一个接口”,而是建立一层统一的模型接入抽象,让后面的工具系统、记忆系统和调度逻辑都能稳定复用。

学习目标

完成本章后,你应该能够:

  • 定义统一的模型调用接口
  • 封装 OpenAI 风格的聊天补全接口
  • 使用兼容层复用多个 OpenAI 协议平台
  • 使用适配器接入 Claude 这类非兼容 Provider
  • 实现流式输出处理
  • 实现结构化输出与重试解析
  • 管理上下文长度与 Token 消耗
  • 完成一个简单的多模型路由网关

本章内容

  • 为什么要先定义统一的 LLMProvider 接口
  • 如何实现一个最小可用的 OpenAI 风格客户端
  • 如何通过 OpenAI 兼容协议接入豆包和通义千问
  • 为什么 Claude 需要单独做适配
  • 如何处理流式输出、提示词模板和结构化结果
  • 如何控制 Token 预算与上下文窗口
  • 如何实现一个支持故障转移的多模型路由网关

一、为什么要先定义统一接口

很多同学第一次接入 LLM 接口时,通常会直接写一段 HTTP 请求代码,请求成功后再解析 JSON 返回值。

这种写法在项目只有一个 Provider 的时候问题不大,但一旦接入第二家、第三家模型平台,代码很快就会开始失控。

常见情况包括:

  • OpenAI、豆包、通义千问的请求路径和模型名称不同
  • Claude 的请求结构与 OpenAI 风格并不一致
  • 有的场景需要同步返回,有的场景需要流式返回
  • Provider 出现限流或超时时,需要自动切换到其他模型

如果没有统一抽象,业务代码里就会很快出现大量分支判断。

更合理的做法,是先把上层真正关心的能力抽象成接口。

type LLMProvider interface {
    Complete(ctx context.Context, req CompletionRequest) (CompletionResponse, error)
    Stream(ctx context.Context, req CompletionRequest) (<-chan StreamChunk, <-chan error)
    Name() string
}

这里需要注意两点。

第一,CompleteStream 最好一开始就统一到接口层,而不是只抽同步调用。因为后面无论是终端工具、网页聊天窗口还是 SSE 转发,都会用到流式输出。

第二,接口层统一的是“能力”,不是底层协议细节。上层只关心能不能请求模型、能不能流式返回,不应该关心某个平台的 system 字段放在哪里,或者某个请求头应该怎么拼。

二、统一请求结构

为了让多个 Provider 能够复用同一套上层逻辑,我们通常还需要定义一套项目内部统一的请求和响应结构。

例如:

type CompletionRequest struct {
    Model       string    `json:"model"`
    Messages    []Message `json:"messages"`
    Temperature float64   `json:"temperature,omitempty"`
    MaxTokens   int       `json:"max_tokens,omitempty"`
    Stream      bool      `json:"stream,omitempty"`
    Tools       []Tool    `json:"tools,omitempty"`
}

这个结构体不一定与某个 Provider 的原始协议完全一致,但它应该足够表达业务层真正需要的字段。

后面接入不同平台时,可以通过“兼容复用”或者“适配转换”的方式,把这份统一请求结构转换成各家接口真正需要的格式。

这种做法有两个直接好处:

  • 业务层只处理一种请求结构,逻辑更稳定
  • 新增 Provider 时改动面更小,不需要一路改到上层业务代码

三、OpenAI 风格客户端实现

课程里先从 OpenAI 风格接口讲起,主要是因为它比较适合作为第一版统一实现的基础。

先看一个最小可用的客户端实现:

type OpenAIClient struct {
    httpClient *http.Client
    apiKey     string
    baseURL    string
}

func (c *OpenAIClient) Complete(ctx context.Context, req CompletionRequest) (CompletionResponse, error) {
    body, err := json.Marshal(req)
    if err != nil {
        return CompletionResponse{}, err
    }

    httpReq, err := http.NewRequestWithContext(
        ctx,
        http.MethodPost,
        c.baseURL+"/chat/completions",
        bytes.NewReader(body),
    )
    if err != nil {
        return CompletionResponse{}, err
    }

    httpReq.Header.Set("Authorization", "Bearer "+c.apiKey)
    httpReq.Header.Set("Content-Type", "application/json")

    resp, err := c.httpClient.Do(httpReq)
    if err != nil {
        return CompletionResponse{}, err
    }
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusOK {
        return CompletionResponse{}, parseAPIError(resp)
    }

    var result CompletionResponse
    if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
        return CompletionResponse{}, err
    }
    return result, nil
}

这段代码本身不复杂,但有几个点值得注意。

1. baseURL 不要写死

很多人第一版代码会直接把 OpenAI 的地址写在函数内部,这样后面接兼容平台时,就只能复制一份新代码继续改。

比较好的方式是把 baseURL 放进配置中。后面你会看到,这一步对多 Provider 复用非常关键。

2. 错误处理要尽量完整

调用 LLM 接口时,常见错误不只有网络错误,还包括:

  • API Key 配置错误
  • 参数不合法
  • 上下文超长
  • 速率限制
  • 上游返回异常响应

如果一开始就把错误处理做得比较规范,后面排查问题会轻松很多。

3. 建议使用 httptest 做客户端测试

这一类代码很适合使用 httptest 做单元测试。你可以自己模拟成功响应、异常响应、超时和非法 JSON 响应,这样就能比较完整地验证客户端逻辑是否正确。

四、OpenAI 兼容协议与多 Provider 复用

这一节是整个模块中非常重要的一部分。

很多同学一提到“接入第二家模型厂商”,第一反应就是再写一个新的客户端。实际情况并不总是这样。

豆包和通义千问都提供了 OpenAI 兼容接口。也就是说,如果你前面的抽象设计得合理,那么接入这两家平台时,核心工作不是重写请求逻辑,而是复用现有实现,并替换 BaseURLAPI Key 和默认模型名。

多 Provider 架构图

几个平台的入口地址大致如下:

OpenAI:  https://api.openai.com/v1
豆包:    https://ark.cn-beijing.volces.com/api/v3
千问:    https://dashscope.aliyuncs.com/compatible-mode/v1

抽一个兼容层共用实现

如果你使用 go-openai 这类 SDK,可以把 OpenAI 协议族平台抽成一个共用 Provider:

type openAICompatProvider struct {
    name   string
    model  string
    client *openai.Client
}

func newCompatProvider(name, model, baseURL, apiKey string) *openAICompatProvider {
    cfg := openai.DefaultConfig(apiKey)
    if baseURL != "" {
        cfg.BaseURL = baseURL
    }
    return &openAICompatProvider{
        name:   name,
        model:  model,
        client: openai.NewClientWithConfig(cfg),
    }
}

这段代码的重点不在于 SDK 本身,而在于思路:同一协议族的平台,尽量复用一套基础实现。

接入豆包

const DoubaoBaseURL = "https://ark.cn-beijing.volces.com/api/v3"

func NewDoubao(apiKey, model string) LLMProvider {
    if model == "" {
        model = "doubao-1.5-pro-32k"
    }
    return newCompatProvider("doubao", model, DoubaoBaseURL, apiKey)
}

接入通义千问

const QianwenBaseURL = "https://dashscope.aliyuncs.com/compatible-mode/v1"

func NewQianwen(apiKey, model string) LLMProvider {
    if model == "" {
        model = "qwen-plus"
    }
    return newCompatProvider("qianwen", model, QianwenBaseURL, apiKey)
}
同一协议族的平台,优先考虑兼容层复用;不要每接一家平台,就复制一份新的客户端实现。

五、兼容层与适配器

讲完 OpenAI 兼容层之后,还需要明确一点:不是所有模型平台都适合直接复用这套实现。

Claude 就是一个很典型的例子。

它与 OpenAI 风格接口的差异主要体现在下面几个方面:

  • system 是独立字段,不放在 messages 数组中
  • 流式事件格式更丰富
  • Tool Use 的响应结构也不同

例如:

type ClaudeRequest struct {
    Model     string    `json:"model"`
    MaxTokens int       `json:"max_tokens"`
    System    string    `json:"system,omitempty"`
    Messages  []Message `json:"messages"`
}

兼容层 vs 适配器关系图

这时更好的做法,是保留统一的 CompletionRequest,然后在 Claude Provider 内部完成协议转换。

func (c *ClaudeProvider) Complete(ctx context.Context, req CompletionRequest) (CompletionResponse, error) {
    claudeReq := c.adaptRequest(req)
    // 发送请求
    // 解析响应
    // 转换为统一 CompletionResponse
}

这一部分的关键不是“怎么把请求发出去”,而是“如何把差异封装在 Provider 内部”。

上层业务不应该知道 Claude 的字段布局和事件格式,只应该依赖统一的调用能力。

可以把这两种思路简单理解成:兼容层主要复用实现,适配器主要负责转换协议。

六、流式输出处理

在聊天窗口、终端工具和实时响应场景中,流式输出几乎是必需能力。

这里通常会涉及 SSE(Server-Sent Events)协议解析。

下面是一个通用的流处理框架:

func ProcessSSEStream[T any](
    ctx context.Context,
    body io.ReadCloser,
    parser func([]byte) (T, bool, error),
) (<-chan T, <-chan error) {
    items := make(chan T, 64)
    errs := make(chan error, 1)

    go func() {
        defer close(items)
        defer close(errs)
        defer body.Close()

        scanner := bufio.NewScanner(body)
        for scanner.Scan() {
            line := scanner.Text()
            if !strings.HasPrefix(line, "data: ") {
                continue
            }

            data := strings.TrimPrefix(line, "data: ")
            item, done, err := parser([]byte(data))
            if err != nil {
                errs <- err
                return
            }
            if done {
                return
            }

            select {
            case <-ctx.Done():
                return
            case items <- item:
            }
        }
    }()

    return items, errs
}

这里建议把流式处理拆成两层:

  • 第一层只负责读取流和分发数据
  • 第二层由不同 Provider 提供各自的事件解析器

这样一来,OpenAI、豆包、千问这种结构接近的平台可以共用一套解析主流程,而 Claude 则可以使用自己的解析器。

实现流式处理时,还要注意几个常见问题:

  • Scanner 默认大小有限制,超长内容可能需要手动调整
  • 上游断流后要及时退出 goroutine,避免泄漏
  • 错误要通过单独通道向上层返回
  • 展示逻辑不要直接写在解析层中

七、提示词模板系统

刚开始写 Prompt 时,很多人会直接用字符串拼接:

prompt := "你是一个助手,请根据用户输入完成总结:" + userInput

简单场景下这样写没问题,但只要稍微复杂一点,很快就会变得难以维护。

例如:

  • 需要注入历史消息
  • 需要按任务动态插入工具列表
  • 需要区分不同模型的提示词版本
  • 需要维护多个任务模板

这时更适合使用模板引擎来管理 Prompt。

const ReActPromptTemplate = `You are an AI assistant with access to tools.

Available tools:
{{range .Tools}}- {{.Name}}: {{.Description}}
{{end}}

Current conversation:
{{range .History}}{{.Role}}: {{.Content}}
{{end}}

User: {{.UserInput}}`

type PromptBuilder struct {
    tmpl *template.Template
}

func (b *PromptBuilder) Build(data PromptData) (string, error) {
    var buf bytes.Buffer
    err := b.tmpl.Execute(&buf, data)
    return buf.String(), err
}

在 Agent 项目中,Prompt 通常不是一次性文本,而是一类会长期维护的配置。

比较常见的做法,是至少维护下面三类模板:

  • 普通对话类模板
  • 工具调用类模板
  • 结构化输出类模板

八、结构化输出与 JSON Schema

在 Agent 项目中,很多时候我们并不希望模型返回一大段自然语言,而是希望它输出一个结构稳定的结果。

例如:

  • 意图识别结果
  • 任务规划结果
  • 路由决策结果
  • 工具调用参数

这一类场景通常有两种常见做法。

方案一:使用原生结构化输出能力

如果 Provider 支持 response_formatjson_schema 等能力,优先使用原生约束。

req.ResponseFormat = &ResponseFormat{
    Type: "json_schema",
    JSONSchema: &JSONSchema{
        Name:   "task_analysis",
        Schema: generateSchema[TaskAnalysis](),
        Strict: true,
    },
}

这种方式的优点是输出约束更强,解析失败的概率通常更低。

方案二:提示词约束加解析重试

并不是所有 Provider 都支持稳定一致的结构化输出能力,因此还需要准备兜底方案。

func ParseWithRetry[T any](ctx context.Context, llm LLMProvider, prompt string, maxRetries int) (T, error) {
    var zero T
    for i := 0; i < maxRetries; i++ {
        resp, err := llm.Complete(ctx, CompletionRequest{/* ... */})
        if err != nil {
            continue
        }

        var result T
        if err := json.Unmarshal([]byte(extractJSON(resp.Content)), &result); err == nil {
            return result, nil
        }

        prompt += "\nPlease respond with valid JSON only."
    }
    return zero, errors.New("failed to parse structured output")
}
能用原生 JSON Schema 时优先使用;不能用时,再使用提示词约束加解析重试兜底。

九、Token 预算与上下文管理

模型调用不仅影响结果质量,也直接影响成本、延迟和上下文窗口使用情况。

因此在工程实践中,Token 管理通常不是一个附属问题,而是模型接入层必须处理的一部分。

下面是一个简单的计数示例:

import "github.com/pkoukk/tiktoken-go"

func CountTokens(model, text string) (int, error) {
    enc, err := tiktoken.EncodingForModel(model)
    if err != nil {
        return 0, err
    }
    return len(enc.Encode(text, nil, nil)), nil
}

有了计数能力之后,就可以进一步实现上下文裁剪。

func TrimHistory(history []Message, maxTokens int, model string) []Message {
    total := 0
    var result []Message

    for i := len(history) - 1; i >= 0; i-- {
        tokens, _ := CountTokens(model, history[i].Content)
        if total+tokens > maxTokens {
            break
        }
        total += tokens
        result = append([]Message{history[i]}, result...)
    }
    return result
}

这个滑动窗口策略虽然简单,但对第一版系统来说已经很实用。

在此基础上,你还需要继续思考几个问题:

  • 哪些历史消息必须保留
  • 工具返回是否需要先压缩或摘要
  • 不同模型是否应该使用不同的预算策略
  • 长上下文模型是否真的值得默认启用

十、综合实践:多模型路由网关

前面介绍的内容,最终需要落到一个完整的项目中。

本模块的实践项目是一个多模型路由网关。它的目标不是实现一个复杂的平台,而是把这一节介绍的几个核心能力串起来。

项目目标

这一版网关至少需要支持:

  • OpenAI
  • Claude
  • 豆包
  • 通义千问
  • Ollama

并且具备下面这些能力:

  • 根据请求类型选择模型
  • Provider 出现错误时自动切换
  • 统计延迟、Token 消耗和预估成本
  • 通过环境变量控制可用 Provider

网关结构示例

type RouterGateway struct {
    providers map[string]LLMProvider
    router    RoutingStrategy
    metrics   *MetricsCollector
}

func (g *RouterGateway) Complete(ctx context.Context, req CompletionRequest) (CompletionResponse, error) {
    provider := g.router.SelectProvider(req)
    start := time.Now()

    resp, err := g.providers[provider].Complete(ctx, req)
    g.metrics.Record(provider, time.Since(start), err)

    if err != nil {
        fallback := g.router.Fallback(provider)
        return g.providers[fallback].Complete(ctx, req)
    }
    return resp, nil
}

Provider 工厂注册

func BuildAll(cfg *Config) map[string]LLMProvider {
    providers := make(map[string]LLMProvider)

    if cfg.ArkAPIKey != "" {
        providers["doubao"] = NewDoubao(cfg.ArkAPIKey, cfg.DoubaoModel)
    }
    if cfg.DashScopeKey != "" {
        providers["qianwen"] = NewQianwen(cfg.DashScopeKey, cfg.QwenModel)
    }
    if cfg.OpenAIKey != "" {
        providers["openai"] = NewOpenAI(cfg.OpenAIKey, cfg.OpenAIModel)
    }
    if cfg.ClaudeAPIKey != "" {
        providers["claude"] = NewClaude(cfg.ClaudeAPIKey, cfg.ClaudeModel)
    }

    providers["ollama"] = NewOllama(cfg.OllamaBaseURL, cfg.OllamaModel)
    return providers
}

一个可参考的默认顺序

order := []string{"doubao", "qianwen", "openai", "claude", "ollama"}

这不是唯一正确答案,只是一个便于开始的默认策略。

在国内网络环境下,很多时候可以优先考虑延迟更低、成本更容易控制的国内模型;复杂任务再回退到 OpenAI 或 Claude;本地 Ollama 则适合作为最后一道兜底。

路由网关的价值,不是把多个模型“摆在一起”,而是把它们组织成一套可治理、可扩展、可降级的系统能力。

本章实战

建议按下面的顺序完成这一章的实践:

  1. 实现一个最小可用的 OpenAI Client,支持 CompleteStream
  2. 实现 Claude Provider,在内部完成协议适配
  3. 接入一个 OpenAI 兼容平台,优先推荐豆包或通义千问
  4. 实现 Provider 工厂与环境变量配置
  5. 完成一个支持故障转移的多模型路由网关

课后练习

必做

  1. 实现一个最小可用的 OpenAI Client
  2. 实现 Claude Provider,满足统一 LLMProvider 接口
  3. 接入豆包或通义千问,并通过兼容层复用已有逻辑
  4. 完成一个至少支持 3 个 Provider 的路由网关

选做

  • 做一个 Provider 对比工具,同时输出结果、延迟和预估成本
  • 尝试统计 P50 / P95 延迟
  • 为不同任务类型设计不同的路由策略

小结

这一章的主线可以概括为下面几点:

  1. 先定义统一接口,再接具体 Provider。
  2. 对于 OpenAI 协议族平台,优先考虑兼容层复用。
  3. 对于 Claude 这类非兼容平台,使用适配器封装差异。
  4. 流式输出、提示词模板、结构化输出和 Token 管理,都应该放在同一层统一处理。
  5. 最终通过路由网关把多个模型组织成可用的系统能力。

完成这一章后,你就有了一层比较稳固的模型调用基础。

后面的工具系统、记忆系统、RAG 和多智能体调度,都会建立在这一层之上。

参考资料

  • OpenAI API 文档
  • Anthropic API 文档
  • 火山方舟文档
  • 阿里云百炼文档
  • go-openai
  • tiktoken-go
最后更新于 • Q1mi