众力资讯网

跟着 Golang 全能 Agent 库 covonaut 学习 LL...

跟着 Golang 全能 Agent 库 covonaut 学习 LLM API 协议的封装实现大模型应用开发中,一个绕
跟着 Golang 全能 Agent 库 covonaut 学习 LLM API 协议的封装实现大模型应用开发中,一个绕不开的问题就是:各家厂商的 API 协议千差万别,怎么封装才能既统一又灵活?
OpenAI 用 Chat Completions,Anthropic 用 Messages API,Google Gemini 用 generateContent,AWS Bedrock 用 Converse……协议不同、认证方式不同、流式格式不同、消息结构也不同。如果每个 Provider 都从头写,代码重复且难以维护;如果强行统一,又会丢失各家的特色能力。
covonaut 是一个纯 Go 实现的 Agent 框架,零外部依赖,它的 Provider 层用一种优雅的方式解决了这个问题。今天我们就通过源码来看看它是怎么做的。GitHub 地址是 github.com/covoyage/covonaut
一、统一接口:两个方法搞定一切covonaut 在 agentcore/provider.go 中定义了一个极简的 Provider 接口:
type Provider interface {
Complete(ctx context.Context, req *ProviderRequest) (*ProviderResponse, error)
Stream(ctx context.Context, req *ProviderRequest) (<-chan streamdelta, error) } 就两个方法:complete 做同步调用,stream 做流式调用。所有 provider——不管是 openai、anthropic、gemini 还是 bedrock——都实现这个接口。 输入是 providerrequest,包含模型名、消息列表、工具定义、最大 token 数、响应格式、思考配置等。输出是 providerresponse,包含文本内容、多模态内容块、结构化输出、工具调用、token 用量等。 这个设计的关键在于输入输出的结构是框架自己定义的,跟任何一家厂商的协议都不同。每个 provider 的职责就是做协议转换,把统一的 providerrequest 翻译成自家 api 的请求格式,再把自家 的响应翻译回统一的 providerresponse。 二、消息模型:content + blocks 双层设计在深入各 之前,先理解 covonaut 的消息模型,因为这是所有协议转换的基础。 type message struct { role system user assistant tool content string 纯文本内容 []contentblock 多模态内容块 toolcalls []toolcall 工具调用 toolcallid 工具调用结果关联 name 工具名 ... contentblock kind blockkind text thinking image tool_call url 图片 mediatype signature 思考签名(anthropic) arguments 这里有个巧妙的设计:content 是纯文本,blocks 是结构化的多模态内容块。对于只支持纯文本的 provider,用 content;对于支持多模态的 可以表达文本、思考过程、图片、工具调用等更丰富的内容。 有四种类型: text:普通文本thinking:模型推理过程(extended thinking)image:图片输入tool_call:工具调用这个双层设计让消息转换变得很灵活,简单的场景用 content,复杂的多模态场景用 blocks。 三、chatcompat:一个协议兼容数十家厂商chatcompat 是 中最有趣的 实现,不是某个具体厂商的实现,而是实现了 openai 定义的 chat completions 协议,这个协议已经被数十家厂商采用。 看看 doc.go 中列出的兼容厂商: deepseek、groq、mistral、moonshot (kimi)、together、通义千问、github copilot…… 也就是说,只要一个服务暴露了 post v1 兼容的端点,设置 baseurl 和 apikey 就能用。 3.1 config 的扩展点设计chatcompat 的 不仅仅是简单的 key url,而是提供了几个精心设计的扩展点: protocol apiprotocol ""="Chat" completions, "responses"="Responses" endpointpath 默认 " completions",可覆盖 extraheaders map[string]string 额外 http 头 preparemessages func([]agentcore.message) []agentcore.message 消息预处理 buildextrabody func(*agentcore.providerrequest) map[string]any 注入额外字段 disablesystemprompt bool 某些模型不支持 角色 这几个扩展点各有妙用: EndpointPath:有些厂商虽然兼容 Chat Completions 协议,但路径不同,比如 Groq 用 /openai/v1/chat/completions。ExtraHeaders:GitHub Copilot 需要额外的 Copilot-Integration-Id 和 Editor-Version 头。PrepareMessages:某些模型对消息格式有特殊要求,可以在发送前做转换。BuildExtraBody:注入厂商特有的 JSON 字段,比如安全设置、Provider 标签等。DisableSystemPrompt:DeepSeek 的 reasoner 模型不支持 system 角色,设为 true 就自动过滤掉。这种设计让 chatcompat 成为一个"协议适配器"而不是"厂商适配器",通过配置组合而非代码分支来处理厂商差异。
3.2 Mistral 和 Copilot:薄包装的力量看 mistral 和 copilot 的实现就知道这种设计的威力了:
// mistral/mistral.go — 只有 33 行
func New(cfg Config) agentcore.Provider {
return chatcompat.New(chatcompat.Config{
APIKey: cfg.APIKey,
BaseURL: baseURL, // "https://api.mistral.ai/v1"
})
}

// copilot/copilot.go — 关键逻辑也只有几行
func New(cfg Config) (agentcore.Provider, error) {
token, err := resolveToken(cfg) // 从环境变量或 gh CLI 获取 token
return chatcompat.New(chatcompat.Config{
APIKey: token,
BaseURL: baseURL, // "https://api.githubcopilot.com"
ExtraHeaders: map[string]string{
"Copilot-Integration-Id": "vscode-chat",
"Editor-Version": "vscode/1.95.0",
},
})
}
Mistral 的 Conversations API 与 Chat Completions 协议完全兼容,所以直接委托给 chatcompat,只换了个默认 BaseURL。
Copilot 也是 Chat Completions 兼容的,但多了两个需求:token 自动获取(支持 GITHUB_TOKEN 环境变量和 gh auth token 命令)和额外的 HTTP 头。这些都在 Config 层面解决,不需要动协议转换逻辑。
3.3 Responses API:同一 Provider 内的协议切换chatcompat 还支持 OpenAI 较新的 Responses API。通过 Config.Protocol 字段切换:
type APIProtocol string

const (
APIProtocolChat APIProtocol = "" // Chat Completions
APIProtocolResponses APIProtocol = "responses" // Responses API
)
当 Protocol 设为 "responses" 时,Complete 和 Stream 方法会走 completeResponses 和 streamResponses 分支。Responses API 的请求结构完全不同——用 input 而非 messages,用 instructions 而非 system message,用 text.format 而非 response_format——但最终都转换回统一的 ProviderResponse。
这个设计告诉我们:同一个 Provider 可以内含多个协议实现,通过配置切换而非继承。
3.4 错误处理的厂商兼容不同厂商的错误响应格式也不同。chatcompat 的 formatError 函数处理了多种格式:
func formatError(body []byte) string {
// OpenAI / DeepSeek / Moonshot / Groq: {"error": {"message": "..."}}
// Qwen (DashScope): {"code": "...", "message": "..."}
// 兜底:返回原始 body
}
先尝试 OpenAI 格式(嵌套 error 对象),再尝试 Qwen 格式(平级 code + message),最后兜底返回原始内容。简单有效。
四、Anthropic:content block 模型的优雅转换Anthropic 的 Messages API 跟 Chat Completions 有几个本质区别:
System 消息不在 messages 数组里,而是请求体的顶级 system 字段消息内容是 content block 数组,不是字符串工具调用和结果是 content block 类型(tool_use / tool_result),不是独立字段流式事件有明确的类型:content_block_start / content_block_delta / message_delta / message_stop4.1 消息转换的核心逻辑ConvertMessages 函数处理了所有这些差异:
func ConvertMessages(msgs []agentcore.Message) (string, []apiMessage) {
var system string
var out []apiMessage

for _, m := range msgs {
switch m.Role {
case agentcore.RoleSystem:
system = m.Content // system 提到顶级字段
case agentcore.RoleUser:
out = append(out, apiMessage{
Role: "user", Content: BlocksFromUserMessage(m),
})
case agentcore.RoleAssistant:
// text + thinking + tool_use 全部转为 content block
case agentcore.RoleTool:
// tool_result 转为 user 消息中的 content block
// 连续的 tool_result 合并到同一个 user 消息
}
}
return system, out
}
注意 Anthropic 的一个特殊要求:工具调用结果(tool_result)必须放在 user 角色的消息中。而且连续多个工具结果应该合并到同一个 user 消息里。代码中用了一个巧妙的判断:
case agentcore.RoleTool:
result := contentBlock{Type: "tool_result", ToolUseID: m.ToolCallID, Content: m.Content}
// 如果上一条已经是 user 且包含 tool_result,就追加
if n := len(out); n > 0 && out[n-1].Role == "user" &&
len(out[n-1].Content) > 0 && out[n-1].Content[0].Type == "tool_result" {
out[n-1].Content = append(out[n-1].Content, result)
} else {
out = append(out, apiMessage{Role: "user", Content: []contentBlock{result}})
}
4.2 Extended Thinking 的原生支持Anthropic 的 Extended Thinking 是一个特色能力。covonaut 不仅支持 thinking 内容的传输,还处理了 Anthropic 独有的 signature 字段——这是思考内容的完整性签名,需要在后续请求中原样传回:
case agentcore.BlockKindThinking:
am.Content = append(am.Content, contentBlock{
Type: "thinking",
Thinking: bl.Text,
Signature: bl.Signature, // 完整性签名
})
在流式处理中,还有 signature_delta 事件类型来增量传输签名。这些细节如果不仔细处理,就会导致多轮对话中 thinking 功能失效。
4.3 Prompt Caching 支持Anthropic 支持 prompt caching,通过在 content block 上设置 cache_control 来标记缓存断点。covonaut 在 ContentBlock 中保留了 CacheControl 字段,并在转换时传递给 Anthropic API:
type contentBlock struct {
// ...其他字段
CacheControl *agentcore.CacheControlMarker `json:"cache_control,omitempty"`
}
4.4 结构化输出的降级策略Anthropic 没有原生的 JSON Schema 约束输出能力(不像 OpenAI 的 response_format),covonaut 用了一个巧妙的降级策略——把 JSON Schema 的要求写进 system prompt:
func structuredOutputInstruction(format *agentcore.ResponseFormat) string {
if format == nil { return "" }
// 生成类似 "Return only valid JSON that matches this schema exactly..." 的指令
// 包含完整的 JSON Schema 定义
}
然后在 buildRequest 中追加到 system 字段。这样即使 Provider 不支持原生的结构化输出,也能通过 prompt 引导来近似实现。
五、Gemini:content/part 二级模型与多模态Gemini 的 API 跟前两者差异更大,它用的是 content/part 二级结构:
generateRequest
├── contents[] // 消息列表
│ └── content
│ ├── role // "user" / "model" / "function"
│ └── parts[] // 一个消息可以有多个 part
│ ├── text
│ ├── thought + thoughtSignature
│ ├── inlineData (base64 图片)
│ ├── fileData (GCS/File URI)
│ ├── functionCall
│ └── functionResponse
├── systemInstruction // 系统指令(独立字段)
├── tools[]
│ └── functionDeclarations[]
└── generationConfig
├── temperature
├── maxOutputTokens
├── responseMimeType / responseSchema
└── thinkingConfig
5.1 角色映射的差异Gemini 的角色命名跟 OpenAI 不同:
assistant → modeltool → function而且工具调用结果的传递方式也不同:不是作为独立消息,而是 functionResponse 类型的 part,放在 function 角色的 content 中:
case agentcore.RoleTool:
fr := part{FunctionResponse: &functionResponse{
Name: m.Name, ID: m.ToolCallID, Response: respData,
}}
// 连续的 function response 合并到同一个 "function" 角色消息
if n := len(out); n > 0 && out[n-1].Role == "function" {
out[n-1].Parts = append(out[n-1].Parts, fr)
} else {
out = append(out, content{Role: "function", Parts: []part{fr}})
}
5.2 多模态输入的三种方式Gemini 支持三种图片输入方式,covonaut 都做了处理:
func ImagePartFromBlock(bl agentcore.ContentBlock) (part, bool) {
// 1. data URL → inlineData (base64 编码)
if data, mime, ok := ParseDataURL(bl.URL, bl.MediaType); ok {
return part{InlineData: &inlineData{MIMEType: mime, Data: data}}, true
}
// 2. GCS/File URI → fileData
if strings.HasPrefix(bl.URL, "gs://") || strings.HasPrefix(bl.URL, "file://") {
return part{FileData: &fileData{MIMEType: bl.MediaType, FileURI: bl.URL}}, true
}
return part{}, false
}
data URL 解析成 inlineData,gs:// 和 file:// URI 映射到 fileData。这个 ParseDataURL 函数在 gemini 和 anthropic 中都有实现,逻辑相同但各自维护——因为两个 API 对图片的传输格式要求不同(Anthropic 用 source.type=base64,Gemini 用 inlineData)。
5.3 Thinking 的不同表达Gemini 的思考能力用 thought 布尔标记 + thoughtSignature 字段来表示,跟 Anthropic 的 thinkingcontent block 类型不同:
case agentcore.BlockKindThinking:
parts = append(parts, part{
Text: bl.Text,
Thought: true, // Gemini 用布尔标记
ThoughtSignature: bl.Signature, // 签名
})
在 generationConfig 中还有专门的 thinkingConfig:
type thinkingConfig struct {
IncludeThoughts bool `json:"includeThoughts"`
ThinkingBudget *int64 `json:"thinkingBudget,omitempty"`
}
ThinkingBudget 可以限制推理 token 数,这是 Gemini 独有的控制粒度。
六、Bedrock:零依赖的 AWS SigV4 签名Bedrock 是 AWS 的模型托管服务,用 Converse API 交互。它的特殊之处在于认证——需要 AWS SigV4 签名。
大多数 Go 项目会用 AWS SDK 来处理签名,但 covonaut 的原则是零外部依赖。所以在 bedrock/signer.go中,用纯 Go 实现了完整的 SigV4 签名流程:
func signRequest(req *http.Request, body []byte, accessKey, secretKey, sessionToken, region string) error {
// 1. 构造规范请求(Canonical Request)
// 2. 构造待签字符串(String to Sign)
// 3. 派生签名密钥(Signing Key)
// 4. 计算 HMAC-SHA256 签名
// 5. 设置 Authorization 头
}
核心步骤:
用 HMAC-SHA256 逐层派生签名密钥:AWS4 + SecretKey → Date → Region → Service → aws4_request对规范请求做 SHA256 哈希后签名设置 Authorization: AWS4-HMAC-SHA256 Credential=.../date/region/bedrock/aws4_request, SignedHeaders=..., Signature=...Bedrock 的 Converse API 消息格式相对简单,用 map[string]any 动态构建而非强类型结构体:
func convertMessages(msgs []agentcore.Message) (converseMsgs []map[string]any, system []map[string]any) {
for _, msg := range msgs {
if msg.Role == agentcore.RoleSystem {
system = append(system, map[string]any{"text": msg.Content})
continue
}
role := "user"
if msg.Role == agentcore.RoleAssistant { role = "assistant" }
converseMsgs = append(converseMsgs, map[string]any{
"role": role,
"content": []map[string]any{{"text": msg.Content}},
})
}
return
}
流式处理也跟其他 Provider 不同——Bedrock 的 ConverseStream 返回的是 AWS eventstream 格式,不是标准 SSE。代码中用 bytes.IndexByte(line, '{') 来定位 JSON 起始位置,跳过 eventstream 的二进制头部。
七、Vertex AI:组合复用的典范Vertex AI 的实现是 covonaut 中组合复用思想最好的体现。
Vertex 上可以跑两种模型:Gemini 和 Anthropic。covonaut 没有为它们各写一套完整的协议转换,而是:
Gemini on Vertex:直接复用 gemini Provider,只换 BaseURLfunc NewGemini(cfg Config) (agentcore.Provider, error) {
baseURL := fmt.Sprintf("https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google",
cfg.Region, cfg.Project, cfg.Region)
return gemini.New(gemini.Config{
APIKey: token, // GCP access token
BaseURL: baseURL, // Vertex 端点
})
}
因为 Vertex 上的 Gemini 用的是完全相同的 REST API,只是端点不同、认证方式不同(用 GCP OAuth 而非 API Key)。
Anthropic on Vertex:用 Vertex 的 streamRawPredict 端点,请求格式跟 Anthropic Messages API 基本一致func (p *anthropicVertex) endpoint(model string) string {
return fmt.Sprintf("https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/anthropic/models/%s:streamRawPredict",
p.region, p.project, p.region, model)
}
Token 的自动获取也很实用——依次尝试配置值、gcloud auth print-access-token 命令,让开发体验更流畅。
八、流式处理:三种 SSE 变体虽然各家都声称用 SSE(Server-Sent Events),但细节差异很大。covonaut 处理了三种变体:
8.1 OpenAI 风格(chatcompat)data: {"id":"...","choices":[{"delta":{"content":"Hello"}}]}
data: {"id":"...","choices":[{"delta":{"content":" world"}}]}
data: [DONE]
特点:data: 前缀,[DONE] 终止符,增量内容在 delta 中。
8.2 Anthropic 风格event: content_block_start
data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}

event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}

event: message_stop
data: {"type":"message_stop"}
特点:有 event: 行标识事件类型,不同类型对应不同的数据结构。需要跟踪 block index 来区分 text 和 tool_use 的增量。
covonaut 用一个 blockInfo map 来跟踪:
type blockInfo struct {
kind string // "text" / "tool_use"
id string
name string
}
blocks := map[int64]*blockInfo{}
8.3 Gemini 风格data: {"candidates":[{"content":{"parts":[{"text":"Hello"}]}}]}

data: {"candidates":[{"content":{"parts":[{"text":" world"}]}}],"usageMetadata":{...}}
特点:每个 data 行是一个完整的 generateResponse,包含 candidates 数组。通过 ?alt=sse 查询参数启用流式。
九、设计总结:covonaut 的封装哲学covonaut 封装 LLM API 协议的几个核心原则:
9.1 接口极简,转换分散Provider 接口只有两个方法,但每个 Provider 的转换逻辑是独立的、完整的。没有用继承、没有用中间层,每个 Provider 就是一个自包含的协议翻译器。
9.2 配置优于继承chatcompat 通过 Config 的扩展点(EndpointPath、ExtraHeaders、PrepareMessages、BuildExtraBody、DisableSystemPrompt)来处理厂商差异,而不是通过子类化。mistral 和 copilot 就是最好的证明——只改配置,不改逻辑。
9.3 组合优于重复Vertex 的 Gemini 实现直接复用 gemini Provider,只换端点和认证。mistral 和 copilot 直接复用 chatcompat。这种组合关系比代码复用更灵活——如果 gemini Provider 修了个 bug,Vertex 上的 Gemini 自动受益。
9.4 零依赖,纯标准库从 HTTP 请求到 SSE 解析到 AWS SigV4 签名,全部用 Go 标准库实现。没有引入任何第三方 SDK。这给了框架完全的控制力和最小的依赖面。
9.5 保留厂商特色能力统一接口不等于抹平差异。Anthropic 的 thinking signature、prompt caching,Gemini 的 thinking budget、file URI,Bedrock 的 reasoning content——这些厂商特色能力都通过 ContentBlock 和 ThinkingConfig 等统一结构传递,而不是丢弃。
写在最后封装 LLM API 协议的本质不是"统一",而是"翻译"。每种 API 协议都有自己的语义模型——OpenAI 的 flat messages、Anthropic 的 content blocks、Gemini 的 content/part 二级结构、Bedrock 的 Converse 格式。好的封装不是强行把它们压成同一个形状,而是定义一个足够表达丰富语义的内部模型,然后为每种协议写一个准确的翻译器。
GitHub: github.com/covoyage/covonaut