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
}这里需要注意两点。
第一,Complete 和 Stream 最好一开始就统一到接口层,而不是只抽同步调用。因为后面无论是终端工具、网页聊天窗口还是 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 兼容接口。也就是说,如果你前面的抽象设计得合理,那么接入这两家平台时,核心工作不是重写请求逻辑,而是复用现有实现,并替换 BaseURL、API Key 和默认模型名。

几个平台的入口地址大致如下:
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"`
}
这时更好的做法,是保留统一的 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_format、json_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")
}九、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 则适合作为最后一道兜底。
本章实战
建议按下面的顺序完成这一章的实践:
- 实现一个最小可用的 OpenAI Client,支持
Complete和Stream - 实现 Claude Provider,在内部完成协议适配
- 接入一个 OpenAI 兼容平台,优先推荐豆包或通义千问
- 实现 Provider 工厂与环境变量配置
- 完成一个支持故障转移的多模型路由网关
课后练习
必做
- 实现一个最小可用的 OpenAI Client
- 实现 Claude Provider,满足统一
LLMProvider接口 - 接入豆包或通义千问,并通过兼容层复用已有逻辑
- 完成一个至少支持 3 个 Provider 的路由网关
选做
- 做一个 Provider 对比工具,同时输出结果、延迟和预估成本
- 尝试统计 P50 / P95 延迟
- 为不同任务类型设计不同的路由策略
小结
这一章的主线可以概括为下面几点:
- 先定义统一接口,再接具体 Provider。
- 对于 OpenAI 协议族平台,优先考虑兼容层复用。
- 对于 Claude 这类非兼容平台,使用适配器封装差异。
- 流式输出、提示词模板、结构化输出和 Token 管理,都应该放在同一层统一处理。
- 最终通过路由网关把多个模型组织成可用的系统能力。
完成这一章后,你就有了一层比较稳固的模型调用基础。
后面的工具系统、记忆系统、RAG 和多智能体调度,都会建立在这一层之上。
参考资料
- OpenAI API 文档
- Anthropic API 文档
- 火山方舟文档
- 阿里云百炼文档
go-openaitiktoken-go