引入 Canonical Model 和 ProtocolAdapter 架构,支持 OpenAI/Anthropic 协议间 无缝转换,统一 ProxyHandler 替代分散的 OpenAI/Anthropic Handler,简化 ProviderClient 为协议无关的 HTTP 发送器,Provider 新增 protocol 字段。
289 lines
15 KiB
Markdown
289 lines
15 KiB
Markdown
## Context
|
||
|
||
### 现有架构
|
||
|
||
当前后端协议转换以 OpenAI 类型为内部枢纽,整体结构:
|
||
|
||
```
|
||
Anthropic Handler ──▶ anthropic.ConvertRequest() ──▶ openai.ChatCompletionRequest
|
||
│
|
||
OpenAI Handler ──────────────────────────────────▶ openai.ChatCompletionRequest
|
||
│
|
||
▼
|
||
ProviderClient
|
||
(硬编码 OpenAI Adapter)
|
||
│
|
||
▼
|
||
上游 OpenAI 兼容 API
|
||
```
|
||
|
||
关键文件:
|
||
- `internal/protocol/openai/types.go` — OpenAI 线路格式类型,兼作内部枢纽格式
|
||
- `internal/protocol/anthropic/converter.go` — Anthropic→OpenAI 单向转换
|
||
- `internal/protocol/anthropic/stream_converter.go` — OpenAI chunk→Anthropic SSE 单向流式转换
|
||
- `internal/handler/openai_handler.go` — OpenAI 请求处理
|
||
- `internal/handler/anthropic_handler.go` — Anthropic 请求处理,内含协议转换编排
|
||
- `internal/provider/client.go` — HTTP 客户端,硬编码 `openai.Adapter` 做序列化/反序列化
|
||
|
||
### 核心限制
|
||
|
||
1. **单向转换**:只有 Anthropic→OpenAI,无反向能力
|
||
2. **OpenAI 绑定**:上游通信只能走 OpenAI 协议
|
||
3. **无透传**:即使 client==provider(同协议),仍走完整序列化/反序列化
|
||
4. **无扩展性**:新增协议需修改多处代码,无统一接入点
|
||
5. **仅 Chat**:只支持 `/v1/chat/completions` 和 `/v1/messages` 两个固定端点,无 Models/Embeddings/Rerank
|
||
|
||
### 设计参考
|
||
|
||
三份设计文档已完整定义目标架构和两个协议的适配细节:
|
||
- `docs/conversion_design.md` — 整体架构(Hub-and-Spoke、Canonical Model、ProtocolAdapter 接口、ConversionEngine、流式管道、错误处理)
|
||
- `docs/conversion_openai.md` — OpenAI 协议适配清单(字段映射、流式状态机、角色合并等)
|
||
- `docs/conversion_anthropic.md` — Anthropic 协议适配清单(角色约束、thinking、流式命名事件等)
|
||
|
||
## Goals / Non-Goals
|
||
|
||
**Goals:**
|
||
|
||
- 实现完整的 Hub-and-Spoke 协议转换架构,以 Canonical Model 为枢纽
|
||
- 支持任意协议对的请求/响应双向转换(当前:OpenAI ↔ Anthropic)
|
||
- 支持同协议透传(零语义损失、零序列化开销)
|
||
- 支持流式 SSE 双向转换(含 Tool Calling、Thinking)
|
||
- 支持 Chat 核心层 + Models/Embeddings/Rerank 扩展层 + 未知接口透传
|
||
- ProviderClient 支持多协议上游通信
|
||
- 统一代理入口,URL 路由支持协议前缀
|
||
|
||
**Non-Goals:**
|
||
|
||
- 本阶段不实现多模态(Image/Audio/Video),Canonical Model 仅预留扩展点
|
||
- 不实现 Middleware 的具体业务逻辑(仅定义接口和 Chain)
|
||
- 不实现新的协议 Adapter(除 OpenAI 和 Anthropic 外)
|
||
- 不实现有状态特性(架构预留 StatefulMiddleware 接口)
|
||
- 不实现前端管理界面的协议选择功能
|
||
- 不修改前端代码(前端使用管理 API,代理 API 路由变更对前端透明)
|
||
|
||
## Decisions
|
||
|
||
### D1: Canonical Model 用独立 Go 结构体实现,不使用 `interface{}` 或 `map[string]any`
|
||
|
||
**选择**:为 CanonicalRequest、CanonicalResponse、CanonicalStreamEvent 等定义强类型 Go struct,ContentBlock 使用 discriminated union 模式(type 字段 + 各类型嵌入)
|
||
|
||
**理由**:
|
||
- 编译期类型安全,IDE 自动补全和重构友好
|
||
- 性能优于 `map[string]any`(无反射开销)
|
||
- 与 Go 生态的习惯一致
|
||
|
||
**替代方案**:
|
||
- `map[string]any` — 灵活但无类型安全,重构时容易遗漏字段
|
||
- 代码生成(如 protobuf)— 引入新依赖和构建步骤,过度工程化
|
||
|
||
**实现细节**:
|
||
|
||
```go
|
||
type ContentBlock struct {
|
||
Type string `json:"type"`
|
||
// Text
|
||
Text string `json:"text,omitempty"`
|
||
// ToolUse
|
||
ID string `json:"id,omitempty"`
|
||
Name string `json:"name,omitempty"`
|
||
Input json.RawMessage `json:"input,omitempty"`
|
||
// ToolResult
|
||
ToolUseID string `json:"tool_use_id,omitempty"`
|
||
IsError *bool `json:"is_error,omitempty"`
|
||
// Thinking
|
||
Thinking string `json:"thinking,omitempty"`
|
||
}
|
||
```
|
||
|
||
使用 `json.RawMessage` 保留 Tool Input 的原始 JSON,避免不必要的 `map` 解析。
|
||
|
||
### D2: ProtocolAdapter 接口集中定义所有方法,不用接口组合
|
||
|
||
**选择**:一个大的 `ProtocolAdapter` 接口包含所有方法(Chat、流式、扩展层、错误编码),不拆分为小接口
|
||
|
||
**理由**:
|
||
- 对照 `docs/conversion_design.md` §5.2 的定义,接口集中便于明确所有应实现的内容
|
||
- Adapter 实现者可一目了然看到所有方法
|
||
- 不支持的功能直接返回 false(`supportsInterface`)或空实现
|
||
- `detectInterfaceType` 由各协议 Adapter 实现,因为不同协议有不同的 URL 路径约定
|
||
|
||
**替代方案**:
|
||
- 接口组合(ChatAdapter + StreamAdapter + ExtendedAdapter)——增加类型复杂度,Adapter 注册和管理更繁琐
|
||
- 用空接口 + 类型断言——丢失编译期检查
|
||
- `detectInterfaceType` 放在 ConversionEngine 中——违反开闭原则,新增协议需要修改 Engine
|
||
|
||
### D3: StreamDecoder 直接解析原始 SSE 字节流
|
||
|
||
**选择**:`ProviderClient.SendStream()` 返回 `<-chan []byte`(原始 SSE 字节流),`StreamDecoder.processChunk()` 负责拆分 SSE event 并解析 JSON
|
||
|
||
**理由**:
|
||
- SSE 解析与协议语义紧密相关(不同协议的 SSE 格式不同:OpenAI 用 `data:` 无名事件,Anthropic 用命名事件 `event: xxx\ndata: xxx`)
|
||
- 减少中间层,降低内存拷贝
|
||
- ProviderClient 保持最简——只做 HTTP 请求和字节流读取
|
||
|
||
**替代方案**:
|
||
- ProviderClient 内做 SSE 解析——强制所有上游使用同一 SSE 格式,不符合多协议目标
|
||
- 独立 SSE Parser 层——增加不必要的抽象,SSE 格式本身就是 Adapter 职责的一部分
|
||
|
||
### D4: ProviderClient 接受 `HTTPRequestSpec`,返回 `*HTTPResponseSpec`
|
||
|
||
**选择**:ConversionEngine 输出 `HTTPRequestSpec{URL, Method, Headers, Body []byte}`,ProviderClient 接收后发送 HTTP 请求;响应返回 `HTTPResponseSpec{StatusCode, Headers, Body []byte}`
|
||
|
||
**理由**:
|
||
- ProviderClient 完全不感知协议,只做 HTTP 通信
|
||
- ConversionEngine 统一负责 URL 构建、Header 构建、Body 序列化
|
||
- 同协议透传时,Engine 直接透传 body bytes,Client 不做任何序列化/反序列化
|
||
|
||
**接口定义**:
|
||
|
||
```go
|
||
type HTTPRequestSpec struct {
|
||
URL string
|
||
Method string
|
||
Headers map[string]string
|
||
Body []byte
|
||
}
|
||
|
||
type HTTPResponseSpec struct {
|
||
StatusCode int
|
||
Headers map[string]string
|
||
Body []byte
|
||
}
|
||
|
||
type ProviderClient interface {
|
||
Send(ctx context.Context, spec HTTPRequestSpec) (*HTTPResponseSpec, error)
|
||
SendStream(ctx context.Context, spec HTTPRequestSpec) (<-chan StreamEvent, error)
|
||
}
|
||
```
|
||
|
||
### D5: 统一代理入口使用 `/{protocol}/v1/...` URL 前缀
|
||
|
||
**选择**:新路由格式 `/{protocol}/v1/{path}`,handler 从 URL 提取 protocol 前缀作为 clientProtocol
|
||
|
||
**理由**:
|
||
- 符合 `docs/conversion_design.md` §2.2 的设计
|
||
- 调用方通过 URL 前缀明确指定协议,无需额外配置
|
||
- 统一入口简化 handler 数量
|
||
|
||
**兼容路由**:不保留旧路由,客户端需迁移到新路由格式。
|
||
|
||
**替代方案**:
|
||
- 保持两个独立 handler——违背统一架构目标
|
||
- 请求体嗅探协议——不可靠,且设计文档明确"协议识别是调用方职责"
|
||
|
||
### D6: Provider 新增 `Protocol` 字段,存储在数据库
|
||
|
||
**选择**:Provider 表新增 `protocol TEXT DEFAULT 'openai'` 列,用于标识上游供应商使用的协议
|
||
|
||
**理由**:
|
||
- 上游供应商可能是 OpenAI 兼容(大多数)或 Anthropic 原生
|
||
- 路由时需要知道 providerProtocol 以选择正确的 Adapter
|
||
- 默认值 `'openai'` 确保现有数据兼容
|
||
|
||
### D7: 删除旧 `internal/protocol/` 包,在 `internal/conversion/` 中全新实现
|
||
|
||
**选择**:直接删除 `internal/protocol/openai/` 和 `internal/protocol/anthropic/`,在 `internal/conversion/` 下对照设计文档全新编写所有代码
|
||
|
||
**理由**:
|
||
- 旧代码的设计模式(OpenAI 类型为枢纽)与新架构根本不同,无法复用
|
||
- 保留旧代码容易导致混用两种模式,引入隐蔽 bug
|
||
- 旧代码中的类型定义不迁移,直接根据设计文档重新定义,确保与新架构一致
|
||
|
||
### D8: 目标包结构
|
||
|
||
```
|
||
internal/conversion/
|
||
canonical/
|
||
types.go # CanonicalRequest/Response/Message/ContentBlock/Tool/ToolChoice/ThinkingConfig/OutputFormat
|
||
stream.go # CanonicalStreamEvent 联合体 + 所有事件类型
|
||
extended.go # CanonicalModelList/ModelInfo/Embedding/Rerank
|
||
errors.go # ConversionError + ErrorCode 枚举
|
||
interface.go # InterfaceType 枚举
|
||
provider.go # TargetProvider struct
|
||
adapter.go # ProtocolAdapter 接口 + AdapterRegistry 接口和实现
|
||
stream.go # StreamDecoder/StreamEncoder/StreamConverter 接口 + Passthrough/Canonical 实现
|
||
middleware.go # ConversionMiddleware 接口 + MiddlewareChain
|
||
engine.go # ConversionEngine 门面 + HTTPRequestSpec/HTTPResponseSpec
|
||
|
||
openai/
|
||
types.go # OpenAI 线路格式类型(对照 conversion_openai.md 全新定义)
|
||
adapter.go # ProtocolAdapter 实现(detectInterfaceType/buildUrl/buildHeaders/supportsInterface/encodeError)
|
||
decoder.go # decodeRequest/decodeResponse/扩展层 decode 方法
|
||
encoder.go # encodeRequest/encodeResponse/扩展层 encode 方法
|
||
stream_decoder.go # OpenAIStreamDecoder(delta chunk 状态机)
|
||
stream_encoder.go # OpenAIStreamEncoder(缓冲策略)
|
||
|
||
anthropic/
|
||
types.go # Anthropic 线路格式类型(对照 conversion_anthropic.md 全新定义)
|
||
adapter.go # ProtocolAdapter 实现(detectInterfaceType/buildUrl/buildHeaders/supportsInterface/encodeError)
|
||
decoder.go # decodeRequest/decodeResponse/扩展层 decode 方法
|
||
encoder.go # encodeRequest/encodeResponse/扩展层 encode 方法
|
||
stream_decoder.go # AnthropicStreamDecoder(命名事件 1:1 映射)
|
||
stream_encoder.go # AnthropicStreamEncoder(直接映射,无缓冲)
|
||
```
|
||
|
||
## Risks / Trade-offs
|
||
|
||
### R1: Anthropic 角色约束处理复杂度高
|
||
**风险**:Anthropic 要求 user/assistant 严格交替、首消息必须为 user、tool_result 必须嵌入 user 消息。从 Canonical 编码为 Anthropic 时需要合并/拆分/注入消息,逻辑容易出错。
|
||
**缓解**:
|
||
- 编写详尽的测试用例覆盖所有边界情况(连续 tool 消息、首条 assistant 消息、空 user 消息注入等)
|
||
- 将角色约束处理封装为独立函数,与内容编码逻辑分离
|
||
|
||
### R2: OpenAI 流式状态机复杂
|
||
**风险**:OpenAI 的 delta chunk 没有显式生命周期(无 start/stop),StreamDecoder 需要状态机推断 block 边界,管理工具调用索引映射和参数累积。
|
||
**缓解**:
|
||
- 严格对照 `docs/conversion_openai.md` §6.2-§6.3 的伪代码实现
|
||
- 为每种 delta 类型编写独立测试(text、tool_calls、reasoning_content、refusal、usage chunk)
|
||
- UTF-8 跨 chunk 截断使用 `utf8Remainder` 缓冲
|
||
|
||
### R3: 全量重构影响范围大
|
||
**风险**:同时删除旧代码、新建包、改造 handler/provider/domain,可能导致系统长时间不可用。
|
||
**缓解**:
|
||
- 旧代码在删除前确认新代码所有测试通过
|
||
- Git 分支隔离开发,完成后再合并
|
||
- 新路由 `/{protocol}/v1/...` 确保协议明确指定
|
||
|
||
### R4: Canonical Model 字段演进
|
||
**风险**:Canonical Model 的字段集反映当前已适配协议的公共语义,未来新增协议时可能需要频繁修改。
|
||
**缓解**:
|
||
- 字段晋升规范已在 `docs/conversion_design.md` 附录 C 中定义
|
||
- `json:"-"` 标签控制序列化输出,新增可选字段不影响已有编解码
|
||
- 协议特有字段不纳入 Canonical,通过同协议透传保留
|
||
|
||
### R5: 性能——双重序列化开销
|
||
**风险**:跨协议转换时经过 decode→canonical→encode 两次序列化/反序列化,相比直接转换多一次拷贝。
|
||
**权衡**:接受此开销以换取架构清晰和可扩展性。同协议透传路径零开销补偿。实际 LLM API 延迟(数百毫秒到数秒)远大于 JSON 序列化开销(微秒级)。
|
||
|
||
## Migration Plan
|
||
|
||
### 步骤
|
||
|
||
1. **创建 `internal/conversion/` 包**:实现 Layer 1-3(Canonical Model、接口定义、Engine),不改动现有代码
|
||
2. **全新实现 OpenAI Adapter 和 Anthropic Adapter**:Layer 4-5,对照设计文档在 conversion 包内全新编写,不沿用旧 protocol 包代码
|
||
3. **编写全面测试**:覆盖编解码、流式转换、错误处理、同协议透传
|
||
4. **改造 `domain.Provider`**:新增 `Protocol` 字段
|
||
5. **创建数据库迁移**:`ALTER TABLE providers ADD COLUMN protocol TEXT DEFAULT 'openai'`
|
||
6. **改造 `ProviderClient`**:简化为接受 `HTTPRequestSpec` 的 HTTP 发送器
|
||
7. **创建 `ProxyHandler`**:统一代理入口,集成 ConversionEngine
|
||
8. **更新 `cmd/server/main.go`**:注册 Adapter、创建 Engine、配置新路由
|
||
9. **删除旧 `internal/protocol/` 包**:直接删除,不迁移代码,确认新架构完全替代
|
||
10. **更新 README.md**:项目结构、API 接口、路由说明
|
||
|
||
### 兼容策略
|
||
|
||
- 旧路由 `/v1/chat/completions` 和 `/v1/messages` 不再保留,客户端需迁移
|
||
- 现有 Provider 数据通过 `DEFAULT 'openai'` 自动获得协议标识
|
||
- 前端管理 API 不受影响
|
||
|
||
### 回滚策略
|
||
|
||
- Git 分支隔离:在新分支开发,合并前充分测试
|
||
- 旧 `internal/protocol/` 包在删除前确认新架构所有测试通过,删除后不可恢复旧代码(从 git 历史仍可找回)
|
||
- 数据库迁移向下兼容(仅 ADD COLUMN)
|
||
|
||
## Open Questions
|
||
|
||
- ~~是否需要为兼容路由 `/v1/chat/completions` 和 `/v1/messages` 设置 deprecation 期限?~~ → **决定**:不保留旧路由,客户端直接迁移到 `/{protocol}/v1/...`
|
||
- ~~扩展层接口(Models/Embeddings/Rerank)在本阶段是否全部实现,还是先做 Models,其余后续迭代?~~ → **决定**:本阶段全部实现(对照三份文档的字段映射已在 spec 中完整定义),因为扩展层接口编解码逻辑量不大(轻量字段映射),且实现后能完整验证引擎的接口分层分发逻辑
|