Provider 多后端架构
当前 Provider 层已经完成 OpenAI 兼容族、DeepSeek/Groq reasoning 字段、Anthropic Messages API 和 provider 内部流式接收聚合的基础适配。剩余架构重点不再是“只支持标准 OpenAI 响应”的早期风险,而是模型能力声明、多模态输入、持久化上下文策略和后续增量输出钩子。
A. 各后端响应格式调研
以下调研基于各厂商公开 API 文档(截至 2026-04)。所有后端可归为三大格式族:
- OpenAI 兼容族:OpenAI、DeepSeek、GLM/智谱、Minimax — 共享
choices[].message扁平结构- Anthropic 族:Claude — 使用
content[]内容块数组结构,与 OpenAI 格式根本不兼容- Google Gemini 族:Gemini 3 — 原生使用
candidates[].content.parts[]结构,但提供 OpenAI 兼容端点
A.1 OpenAI 标准格式(GPT-5 系列)
端点:POST /v1/chat/completions
当前模型系列:gpt-5.2(旗舰)、gpt-5.1、gpt-5、gpt-5-mini、gpt-5-nano,以及 codex/pro 变体。旧版 o1/o3/o4-mini 仍可用但非当前推荐。
标准响应(GPT-5 系列):
{
"id": "chatcmpl-abc123",
"object": "chat.completion",
"created": 1677858242,
"model": "gpt-5.2",
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": "Hello! How can I help you?",
"refusal": null,
"annotations": []
},
"finish_reason": "stop"
}],
"usage": {
"prompt_tokens": 19,
"completion_tokens": 12,
"total_tokens": 31,
"prompt_tokens_details": {
"cached_tokens": 0,
"audio_tokens": 0
},
"completion_tokens_details": {
"reasoning_tokens": 0,
"audio_tokens": 0,
"accepted_prediction_tokens": 0,
"rejected_prediction_tokens": 0
}
}
}推理模型说明(GPT-5 系列均为推理模型):
GPT-5 系列模型均使用 RL 训练,会产生内部推理 token,但在 Chat Completions API 中:
- 推理内容不可见:
reasoning_tokens仅出现在usage.completion_tokens_details中,是计费统计字段 reasoning_effort请求参数控制推理深度:none、minimal、low、medium、high、xhigh(GPT-5.2 默认none,GPT-5 默认medium)- 推理摘要需通过新的
/v1/responses端点获取(reasoning.summary参数:auto/concise/detailed),不通过 Chat Completions API 暴露
关键特征:
- 推理 token 在 Chat Completions API 中始终不可见,仅计费统计
refusal字段:当模型拒绝回答时返回拒绝原因字符串,正常响应为nullannotations字段(新增):消息级注解数组finish_reason取值:stop、length、tool_calls、content_filter- Tool calls 格式:
message.tool_calls[].function.{name, arguments} verbosity参数(新增):控制回复详细程度(low/medium/high)web_search_options参数(新增):内置网页搜索工具
A.2 DeepSeek 格式
端点:POST /chat/completions(OpenAI 兼容)
标准响应(DeepSeek-V3 等):
与 OpenAI 标准格式完全一致。
推理模型响应(DeepSeek-R1):
{
"id": "chatcmpl-abc123",
"object": "chat.completion",
"model": "deepseek-reasoner",
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": "The answer is 42.",
"reasoning_content": "<think>\nLet me analyze this step by step...\nFirst, I need to...\n</think>"
},
"finish_reason": "stop"
}],
"usage": {
"prompt_tokens": 50,
"completion_tokens": 800,
"total_tokens": 850,
"prompt_tokens_details": { "cached_tokens": 0 },
"completion_tokens_details": { "reasoning_tokens": 600 }
}
}关键特征:
reasoning_content:与content同级,位于choices[0].message内,是推理过程的完整文本reasoning_content中的内容可能包含<think/>标签(非固定,取决于模型版本)usage.completion_tokens_details.reasoning_tokens:推理 token 计费统计- 缓存相关 usage 字段:
usage.prompt_cache_hit_tokens、usage.prompt_cache_miss_tokens - 请求侧通过
thinking参数控制是否启用推理模式({"type": "enabled"}/{"type": "disabled"}) - 完全 OpenAI 兼容,可复用 OpenAI 适配器 + 扩展
reasoning_content提取
⚠️ DeepSeek 模型差异(架构影响):
DeepSeek 当前有两个主要模型系列,其能力组合不同,对适配器设计有影响:
| 模型 | 推理/思考 | Tool Calling | 说明 |
|---|---|---|---|
deepseek-reasoner (R1) | 始终开启 | 不支持 | 推理能力最强,但无法使用工具 |
deepseek-chat (V3.2) | 可选开启 | 支持 | 请求侧设置 thinking: {"type": "enabled"} 可同时获得推理+工具 |
deepseek-chat(V3.2) 是推荐方案:可同时启用推理模式和 tool calling,是当前唯一支持"推理+工具"的 DeepSeek 模型deepseek-reasoner(R1) 虽然推理能力强,但不支持 tool calling,在需要工具的场景中受限finish_reason额外取值:insufficient_system_resource(DeepSeek 特有,表示系统资源不足)- DeepSeek 还提供 Anthropic Messages API 兼容端点(
https://api.deepseek.com/anthropic),支持thinking内容块类型,但此端点功能有限(不支持图片、文档等)
A.3 Anthropic/Claude 格式
端点:POST /v1/messages(非 OpenAI 兼容,独立 API 路径和结构)
当前模型系列:claude-sonnet-4-6、claude-opus-4-6、claude-opus-4-1-20250805、claude-sonnet-4-20250514。API 响应格式在 Claude 4.x 系列中保持一致。
标准响应(Claude Sonnet/Opus 等,无 Extended Thinking):
{
"id": "msg_01XFDUDYJgAACzvnptvVoYEL",
"type": "message",
"role": "assistant",
"content": [
{
"type": "text",
"text": "Hello! How can I help you?"
}
],
"model": "claude-sonnet-4-20250514",
"stop_reason": "end_turn",
"stop_sequence": null,
"usage": {
"input_tokens": 25,
"output_tokens": 20
}
}Extended Thinking 响应(Claude 3.7 Sonnet / Claude 4):
{
"id": "msg_01XFDUDYJgAACzvnptvVoYEL",
"type": "message",
"role": "assistant",
"content": [
{
"type": "thinking",
"thinking": "Let me analyze this step by step...\nFirst, I need to consider...",
"signature": "ErUB6pWIDo9Bkx..."
},
{
"type": "text",
"text": "The answer is 42."
}
],
"model": "claude-sonnet-4-20250514",
"stop_reason": "end_turn",
"stop_sequence": null,
"usage": {
"input_tokens": 50,
"output_tokens": 800
}
}Redacted Thinking 响应:
{
"content": [
{
"type": "thinking",
"thinking": "Normal thinking content...",
"signature": "ErUB6pWIDo9Bkx..."
},
{
"type": "redacted_thinking",
"signature": "EqoBCkAaBQ..."
},
{
"type": "text",
"text": "Based on my analysis..."
}
]
}Tool Use 响应:
{
"content": [
{
"type": "text",
"text": "Let me look that up for you."
},
{
"type": "tool_use",
"id": "toolu_01A09q90qw90lq917635",
"name": "get_weather",
"input": { "location": "San Francisco, CA" }
}
],
"stop_reason": "tool_use"
}Interleaved Thinking 响应(Claude 4,tool call 之间的思考):
{
"content": [
{ "type": "thinking", "thinking": "User wants weather...", "signature": "..." },
{ "type": "text", "text": "Let me check the weather." },
{ "type": "tool_use", "id": "toolu_01", "name": "get_weather", "input": { "city": "SF" } },
{ "type": "thinking", "thinking": "Now I have the data, let me summarize...", "signature": "..." },
{ "type": "text", "text": "The weather in SF is sunny." }
],
"stop_reason": "end_turn"
}关键特征:
- 内容块数组:
content是ContentBlock[]而非string,每个块有type字段区分 - 内容块类型:
text、thinking、redacted_thinking、tool_use thinking块包含thinking(文本)和signature(加密签名,用于多轮对话中回传)redacted_thinking不含文本,只有signature(因安全策略被脱敏)- Claude 4 返回的是摘要思考(summarized thinking),非完整推理过程
stop_reason等价于 OpenAI 的finish_reason,完整取值:end_turn、max_tokens、tool_use、stop_sequence、pause_turn(长任务暂停,可续传)、refusal(安全策略拒绝)- Tool calls 位于 content blocks 中(
type: "tool_use"),而非独立tool_calls数组;支持同一响应中多个tool_use块(并行工具调用) - 请求侧需要
thinking参数开启 Extended Thinking,且max_tokens必须足够大(≥ 16000) signature字段必须回传到后续多轮对话中,否则 API 会报错;thinking和redacted_thinking的签名都需原样回传usage.output_tokens在开启 Extended Thinking 时反映计费 token 数(包含完整内部推理),而非可见的摘要 token 数
Anthropic stop_reason 与 OpenAI finish_reason 对照:
Anthropic stop_reason | OpenAI finish_reason | 说明 |
|---|---|---|
end_turn | stop | 正常结束 |
max_tokens | length | 达到 token 上限 |
tool_use | tool_calls | 请求工具调用 |
stop_sequence | (无对应) | 命中自定义停止序列 |
pause_turn | (无对应) | 长任务暂停,可将响应用于续传 |
refusal | content_filter | 安全策略拒绝 |
A.4 GLM/智谱 格式
端点:POST /api/paas/v4/chat/completions(OpenAI 兼容)
标准响应(GLM-4 等):
{
"id": "8748969001",
"created": 1677858242,
"model": "glm-4",
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": "Hello! How can I help you?"
},
"finish_reason": "stop"
}],
"request_id": "8748969001",
"usage": {
"prompt_tokens": 50,
"completion_tokens": 12,
"total_tokens": 62
}
}Web Search 响应(GLM-4 开启 web_search 工具时):
{
"choices": [{
"message": {
"role": "assistant",
"content": "Based on web search results...",
"tool_calls": [{
"id": "call_abc123",
"type": "function",
"function": {
"name": "web_search",
"arguments": "{\"query\": \"latest news\"}"
}
}]
},
"finish_reason": "stop"
}],
"web_search": [
{
"icon": "https://example.com/favicon.ico",
"title": "Example Page",
"link": "https://example.com",
"content": "Page content snippet...",
"media": "example.com"
}
]
}关键特征:
- 完全 OpenAI 兼容,额外字段不影响标准解析
request_id:智谱特有的请求追踪字段web_search:顶层额外字段,包含搜索结果元数据(不影响消息解析)finish_reason额外取值:sensitive(内容敏感被拦截)、network_error- 当前无可见推理/思考链字段(GLM-4 系列)
- 无
refusal字段
A.5 Minimax 格式
端点:POST /v1/text/chatcompletion_v2(OpenAI 兼容)
关键特征:
- 基本兼容 OpenAI 标准格式
- API 路径为
/v1/text/chatcompletion_v2,非标准/v1/chat/completions - 官方文档访问受限,无法确认是否有 Minimax 特有的推理/思考链扩展字段
- 按 OpenAI 标准适配器处理即可,遇到额外字段时按需扩展
A.6 Google Gemini 3 格式
端点(原生):POST https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent
端点(OpenAI 兼容):POST https://generativelanguage.googleapis.com/v1beta/openai/chat/completions
当前模型系列:gemini-3-flash-preview、gemini-3-pro-image-preview 等。
原生响应格式:
{
"candidates": [{
"content": {
"parts": [
{ "text": "Hello! How can I help you?" }
],
"role": "model"
},
"finishReason": "STOP",
"finishMessage": "",
"safetyRatings": [
{ "category": "HARM_CATEGORY_HARASSMENT", "probability": "NEGLIGIBLE" }
],
"tokenCount": 150,
"index": 0
}],
"promptFeedback": {
"blockReason": null,
"safetyRatings": []
},
"usageMetadata": {
"promptTokenCount": 25,
"candidatesTokenCount": 150,
"thoughtsTokenCount": 500,
"totalTokenCount": 675,
"cachedContentTokenCount": 0
},
"modelVersion": "gemini-3-flash-preview",
"responseId": "resp_abc123"
}Thinking/推理响应(includeThoughts: true 时):
{
"candidates": [{
"content": {
"parts": [
{
"text": "Let me analyze this step by step...",
"thought": true
},
{
"text": "The answer is 42."
}
],
"role": "model"
},
"finishReason": "STOP"
}],
"usageMetadata": {
"promptTokenCount": 25,
"candidatesTokenCount": 150,
"thoughtsTokenCount": 500,
"totalTokenCount": 675
}
}Tool Calling 响应:
{
"candidates": [{
"content": {
"parts": [
{ "text": "Let me check that for you." },
{
"functionCall": {
"name": "get_weather",
"args": { "location": "San Francisco, CA" }
}
}
],
"role": "model"
},
"finishReason": "STOP"
}]
}关键特征:
- 原生格式使用
candidates[].content.parts[]结构,与 OpenAI 和 Anthropic 均不同 - 但提供 OpenAI 兼容端点(
/v1beta/openai/chat/completions),可复用 OpenAI 适配器 - Thinking 通过
generationConfig.thinkingConfig.thinkingLevel控制:MINIMAL、LOW、MEDIUM、HIGH(默认HIGH) - Thinking 不可完全禁用(Gemini 3 系列强制启用)
- 思考摘要通过
includeThoughts: true获取,在 parts 中用"thought": true标记 thoughtsTokenCount:推理 token 计费统计- 思考签名(thought signatures)用于多轮对话连续性(与 Anthropic
signature类似) finishReason取值极多(20 个),常见的有:STOP、MAX_TOKENS、SAFETY、RECITATION、MALFORMED_FUNCTION_CALL、MISSING_THOUGHT_SIGNATURE、UNEXPECTED_TOOL_CALL、TOO_MANY_TOOL_CALLS- 原生 tool calls 位于 parts 中(
functionCall/functionResponse),支持并行函数调用 - Gemini 特有字段:
safetyRatings(安全评级)、citationMetadata(引用来源)、groundingMetadata(搜索/地图 grounding)、promptFeedback.blockReason - OpenAI 兼容端点限制:不支持 grounding、code execution 等 Gemini 原生功能
适配策略建议:
- 推荐:使用 OpenAI 兼容端点 + OpenAI 适配器,覆盖基础 chat + tool calling 场景
- 可选扩展:如需 thinking 内容、safety ratings 等原生功能,实现独立的
GeminiNativeAdapter
A.7 OpenAI Responses API 格式
端点:POST /v1/responses
说明:OpenAI 于 2025 年推出的新一代统一端点,替代 /chat/completions。使用完全不同的请求/响应结构,有状态对话链和内置工具。
请求格式:
{
"model": "gpt-5.2",
"input": [
{"role": "developer", "content": [{"type": "input_text", "text": "System prompt"}]},
{"role": "user", "content": [{"type": "input_text", "text": "Hello"}]},
{"role": "assistant", "content": [{"type": "output_text", "text": "Hi there!"}]},
{"role": "user", "content": [
{"type": "input_text", "text": "What's in this image?"},
{"type": "input_image", "image_url": "https://example.com/photo.jpg"}
]}
],
"tools": [
{"type": "function", "name": "get_weather", "description": "...", "parameters": {...}},
{"type": "web_search"},
{"type": "image_generation"}
],
"store": true,
"reasoning": {"effort": "medium"},
"max_output_tokens": 16000,
"previous_response_id": "resp_abc123"
}响应格式:
{
"id": "resp_xyz789",
"object": "response",
"status": "completed",
"output": [
{
"type": "reasoning",
"summary": [{"type": "summary_text", "text": "Reasoning summary"}]
},
{
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": "Response text"}]
},
{
"type": "function_call",
"id": "fc_001",
"call_id": "call_abc",
"name": "get_weather",
"arguments": "{\"location\": \"Tokyo\"}"
},
{
"type": "image_generation_call",
"result": "https://cdn.openai.com/generated-image.png",
"revised_prompt": "A cat sitting on a windowsill"
}
],
"usage": {
"input_tokens": 50,
"output_tokens": 120,
"total_tokens": 170,
"output_tokens_details": {"reasoning_tokens": 30}
}
}与 Chat Completions 的关键差异:
| 特征 | Chat Completions | Responses API |
|---|---|---|
| 端点 | /v1/chat/completions | /v1/responses |
| 消息字段 | messages | input |
| 系统角色 | role: "system" | role: "developer" |
| 文本内容类型 | type: "text" | input_text / output_text |
| 图片内容类型 | image_url | input_image |
| 响应结构 | choices[].message | output[] 扁平数组 |
| 推理内容 | 不可见(仅 reasoning_tokens) | reasoning output item(含 summary) |
| 函数调用 | message.tool_calls[].function | function_call output item(call_id) |
| 工具结果 | role: "tool" + tool_call_id | function_call_output + call_id |
| 有状态对话 | 无(客户端管理) | previous_response_id 链式引用 |
| 内置工具 | 无 | web_search、file_search、code_interpreter、image_generation |
| Token 上限 | max_tokens | max_output_tokens |
| 推理控制 | reasoning_effort(仅计费) | reasoning.effort + 可选 reasoning.summary |
适配策略:
- 独立适配器(
OpenAIResponsesProvider),不继承OpenAICompatibleProvider system角色映射为developerContextPart.image_url→input_image,ContextPart.image_base64→ data URIinput_image- Tool 结果使用
function_call_output类型(非role: "tool") - 图片生成结果存入
ProviderResponse.extra["generated_images"] response.id存入extra["response_id"]用于后续previous_response_id链式调用- 内置工具通过配置
built_in_tools启用,不需要注册为ToolDefinition
A.8 格式族总结
| 特征 | OpenAI (GPT-5) | OpenAI Responses | DeepSeek | Anthropic (Claude) | GLM | Minimax | Gemini 3 |
|---|---|---|---|---|---|---|---|
| API 路径 | /v1/chat/completions | /v1/responses | /chat/completions | /v1/messages | /api/paas/v4/chat/completions | /v1/text/chatcompletion_v2 | 原生: generateContent / 兼容: OpenAI 端点 |
| 格式族 | OpenAI | Responses(独立) | OpenAI | Anthropic(独立) | OpenAI | OpenAI | Gemini(独立)/ OpenAI 兼容 |
| 推理内容 | 不可见(仅 reasoning_tokens) | reasoning output item(summary) | message.reasoning_content | content[].thinking 块 | 无 | 未知 | 原生: parts[].thought=true / 兼容: 不可见 |
| 推理签名 | 无 | 无 | 无 | signature(必须回传) | 无 | 无 | 原生: thought signatures(需回传) |
| 脱敏推理 | 无 | 无 | 无 | redacted_thinking 块 | 无 | 无 | 无 |
| Tool calls | message.tool_calls[] | function_call output item | message.tool_calls[] | content[].tool_use 块 | message.tool_calls[] | message.tool_calls[](推测) | 原生: functionCall parts / 兼容: OpenAI 格式 |
| 内置工具 | 无 | web_search / file_search / code_interpreter / image_generation | 无 | 无 | 无 | 无 | grounding / code_execution |
| 拒绝标记 | message.refusal | 无 | 无 | stop_reason: refusal | 无 | 无 | finishReason: SAFETY |
| 停止原因字段 | finish_reason | status | finish_reason | stop_reason | finish_reason | finish_reason(推测) | finishReason |
| 适配器复用 | 基类 | 独立适配器 | 继承 OpenAI 适配器 | 独立适配器 | 继承 OpenAI 适配器 | 继承 OpenAI 适配器 | 兼容端点用 OpenAI 适配器 / 原生需独立适配器 |
架构结论:
- OpenAI 兼容族(OpenAI、DeepSeek、GLM、Minimax)+ Gemini 兼容端点可共享同一适配器基类,仅通过扩展点处理差异字段
- OpenAI Responses API需要独立适配器,因其使用完全不同的
input/output数组结构、内置工具和有状态对话链 - Anthropic 族需要独立适配器,因其内容块数组结构与扁平 message 结构根本不同
- Gemini 原生端点(如需 thinking/safety/grounding 等原生功能)需要独立适配器,但推荐优先使用 OpenAI 兼容端点
- Anthropic 和 Gemini 均有签名回传需求(
signature/ thought signatures),对上下文管理有特殊要求
B. 集成式 Provider 架构
设计理念:借鉴 AstrBot 的类继承模式 + OpenClaw 的 Provider 即 Plugin 注册思路。每个 Provider 类同时负责 HTTP 传输和响应解析,不使用独立的 ResponseAdapter。通过类继承共享 OpenAI 兼容族的通用逻辑,Anthropic 和 Gemini 各自独立实现。
B.1 扩展 ProviderResponse
# nahida_bot/agent/providers/base.py
@dataclass(slots=True, frozen=True)
class TokenUsage:
"""Token 使用统计。"""
input_tokens: int = 0
output_tokens: int = 0
cached_tokens: int = 0
reasoning_tokens: int = 0
@property
def total(self) -> int:
return self.input_tokens + self.output_tokens
@dataclass(slots=True, frozen=True)
class ProviderResponse:
"""统一的 Provider 响应结构。"""
# 标准字段(已有)
content: str | None
tool_calls: list[ToolCall] = field(default_factory=list)
finish_reason: str | None = None
raw_response: dict[str, object] | None = None
# 推理链(Phase 2.8 新增)
reasoning_content: str | None = None # DeepSeek reasoning_content / Anthropic thinking
reasoning_signature: str | None = None # Anthropic signature / Gemini thought_signature(base64)
has_redacted_thinking: bool = False # Anthropic 存在 redacted_thinking 块
# 拒绝/安全
refusal: str | None = None # OpenAI 内容拒绝原因
# 使用统计
usage: TokenUsage | None = None
# Provider 扩展包
extra: dict[str, object] = field(default_factory=dict)字段设计说明:
reasoning_content:扁平字符串。Anthropic 的 interleaved thinking(多个 thinking 块穿插 tool_use)合并为\n连接的单字符串。如需 per-block 位置元数据,放入extra。reasoning_signature:Anthropic 的signature加密串,或 Gemini 的thought_signature(base64)。对于无签名机制的 Provider(OpenAI、DeepSeek、GLM),保持None。has_redacted_thinking:布尔标记。redacted_thinking块不含文本,仅有签名。标记的存在提醒上下文管理器必须回传签名。extra:Provider 特有数据的逃逸口。例如 GLM 的web_search元数据、Gemini 的safety_ratings、OpenAI 的annotations。避免 ProviderResponse 字段无限膨胀。
B.2 扩展 ContextMessage
# nahida_bot/agent/context.py
@dataclass(slots=True, frozen=True)
class ContextMessage:
"""上下文消息单元。"""
role: MessageRole
content: str
source: str
metadata: dict[str, object] | None = None
# 推理链支持(Phase 2.8 新增,均有默认值,向后兼容)
reasoning: str | None = None
reasoning_signature: str | None = None
has_redacted_thinking: bool = False为什么用扁平字段而非 AstrBot 的 ThinkPart 列表:AstrBot 使用
list[ContentPart](其中ThinkPart是一种变体),这需要 Pydantic 模型层次和自定义反序列化器。nahida-bot 作为聊天机器人,扁平字段更简单,pyright strict 模式下无需类型窄化(type-narrowing)体操。
Agent loop 中的 _build_assistant_message 方法将新字段从 ProviderResponse 传播到 ContextMessage:
# nahida_bot/agent/loop.py(伪代码)
def _build_assistant_message(self, response: ProviderResponse) -> ContextMessage | None:
...
return ContextMessage(
role="assistant",
source="provider_response",
content=response.content or "",
metadata=metadata or None,
reasoning=response.reasoning_content,
reasoning_signature=response.reasoning_signature,
has_redacted_thinking=response.has_redacted_thinking,
)B.3 Provider 类层次
ChatProvider (ABC) # base.py — 现有抽象类,扩展 api_family/format_tools/serialize_messages
├── OpenAICompatibleProvider(_ReasoningMixin) # openai_compatible.py — 演进自当前实现
│ ├── DeepSeekProvider # deepseek.py — @register_provider,空子类
│ ├── GLMProvider # glm.py — @register_provider,空子类
│ ├── GroqProvider # groq.py — reasoning_key="reasoning" + 历史 strip
│ └── MinimaxProvider # minimax.py — @register_provider,空子类
├── AnthropicProvider # anthropic.py — 独立实现(Phase 2.8b)
└── GeminiProvider # gemini.py — 独立实现(Phase 3)ChatProvider 基类扩展:
class ChatProvider(ABC):
"""Provider 基类,被 agent loop 消费。"""
name: str
api_family: str # "openai-completions" | "anthropic-messages" | "google-generative-ai"
@property
@abstractmethod
def tokenizer(self) -> Tokenizer | None: ...
@abstractmethod
async def chat(
self,
*,
messages: list[ContextMessage],
tools: list[ToolDefinition] | None = None,
timeout_seconds: float | None = None,
) -> ProviderResponse: ...
def format_tools(self, tools: list[ToolDefinition]) -> list[object]:
"""将 ToolDefinition 列表转换为 Provider 原生工具格式。默认 OpenAI 格式。"""
return [
{
"type": tool.type,
"function": {
"name": tool.name,
"description": tool.description,
"parameters": tool.parameters,
},
}
for tool in tools
]
def serialize_messages(self, messages: list[ContextMessage]) -> list[dict[str, object]]:
"""将 ContextMessage 列表转换为 Provider 原生请求格式。默认 OpenAI 格式。"""
result: list[dict[str, object]] = []
for msg in messages:
result.append(self._serialize_one_message(msg))
return result
def _serialize_one_message(self, message: ContextMessage) -> dict[str, object]:
"""默认 OpenAI 序列化。子类按 Provider 族覆盖。"""
... # 当前 OpenAICompatibleProvider._serialize_message 的逻辑三族 Provider 的关键差异:
| 维度 | OpenAI 兼容族 | Anthropic 族 | Gemini 族 |
|---|---|---|---|
api_family | "openai-completions" | "anthropic-messages" | "google-generative-ai" |
| HTTP 端点 | POST /v1/chat/completions | POST /v1/messages | 原生: generateContent / 兼容: OpenAI 端点 |
| 响应结构 | choices[].message 扁平 | content[] 内容块数组 | candidates[].content.parts[] |
format_tools() | {"type":"function","function":{...}} | {"name","description","input_schema"} | {"function_declarations":[...]} |
| 推理提取 | reasoning_key 字段 + tag 兜底 | thinking 内容块 | part.thought=true 标记 |
| 签名回传 | 无 | signature 必须回传 | thought_signature 需回传 |
| 工具调用格式 | message.tool_calls[] | content[].tool_use 块 | part.functionCall |
OpenAI 兼容族(当前 OpenAICompatibleProvider 演进):
@dataclass(slots=True)
class OpenAICompatibleProvider(_ReasoningMixin, ChatProvider):
"""OpenAI 兼容 Provider。处理 HTTP 传输 + 响应解析。"""
base_url: str
api_key: str
model: str
name: str = "openai-compatible"
api_family: str = "openai-completions"
tokenizer_impl: Tokenizer | None = None
# ... chat() 方法演进:使用 self._extract_reasoning_from_message() 填充 reasoning_content
# ... serialize_messages() 演进:注入 reasoning_content 到 assistant 消息历史中空子类示例(AstrBot 模式):
# nahida_bot/agent/providers/deepseek.py
@register_provider("deepseek", "DeepSeek Provider")
class DeepSeekProvider(OpenAICompatibleProvider):
"""DeepSeek Provider。reasoning_key 默认 'reasoning_content',无需覆盖。"""
name: str = "deepseek"
# nahida_bot/agent/providers/glm.py
@register_provider("glm", "GLM/ZhiPu Provider")
class GLMProvider(OpenAICompatibleProvider):
"""GLM Provider。完全 OpenAI 兼容,无需任何覆盖。"""
name: str = "glm"
# nahida_bot/agent/providers/groq.py
@register_provider("groq", "Groq Provider")
class GroqProvider(OpenAICompatibleProvider):
"""Groq Provider。reasoning_key 为 'reasoning',且需 strip 历史中的推理字段。"""
name: str = "groq"
reasoning_key: str = "reasoning"
def serialize_messages(self, messages: list[ContextMessage]) -> list[dict[str, object]]:
serialized = super().serialize_messages(messages)
for msg in serialized:
if msg.get("role") == "assistant":
msg.pop("reasoning_content", None)
msg.pop("reasoning", None)
return serializedAnthropic Provider(独立实现):
# nahida_bot/agent/providers/anthropic.py
@register_provider("anthropic", "Anthropic Claude Provider")
class AnthropicProvider(ChatProvider):
"""独立 Anthropic Provider。不继承 OpenAICompatibleProvider。"""
api_family: str = "anthropic-messages"
max_tokens: int = 16000
# format_tools: Anthropic 使用 input_schema 而非 parameters
def format_tools(self, tools: list[ToolDefinition]) -> list[object]:
return [
{
"name": tool.name,
"description": tool.description,
"input_schema": tool.parameters,
}
for tool in tools
]
# serialize_messages: 将 ContextMessage 转换为 Anthropic 格式
# - system 消息提取为独立 system 参数
# - assistant 消息带 reasoning_signature 时注入 thinking 块
# - tool 消息转换为 user/tool_result 格式
def serialize_messages(self, messages: list[ContextMessage]) -> list[dict[str, object]]:
...
# chat(): POST /v1/messages, 解析 content blocks
async def chat(self, ...) -> ProviderResponse:
# 遍历 content blocks:
# text → content
# thinking → reasoning_content + reasoning_signature
# redacted_thinking → has_redacted_thinking = True
# tool_use → ToolCall(call_id, name, input)
...Gemini Provider(Phase 3,使用 OpenAI 兼容端点起步):
# nahida_bot/agent/providers/gemini.py
@register_provider("gemini", "Google Gemini Provider")
class GeminiProvider(ChatProvider):
"""Gemini Provider。初期使用 OpenAI 兼容端点,后续可扩展原生端点。"""
api_family: str = "google-generative-ai"
# 使用 /v1beta/openai/chat/completions 端点时,可复用 OpenAI 兼容逻辑
...B.4 Provider 注册表
借鉴 OpenClaw 的"Provider 即 Plugin"概念,但保持简洁。装饰器 + 模块级字典,与 AstrBot 的 register.py 一致。
# nahida_bot/agent/providers/registry.py
from dataclasses import dataclass
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from nahida_bot.agent.providers.base import ChatProvider
@dataclass(slots=True, frozen=True)
class ProviderDescriptor:
"""已注册 Provider 的元数据。"""
provider_type: str
description: str
cls: type[ChatProvider]
_REGISTRY: dict[str, ProviderDescriptor] = {}
def register_provider(provider_type: str, description: str = ""):
"""装饰器:注册一个 Provider 类。"""
def decorator(cls: type[ChatProvider]) -> type[ChatProvider]:
if provider_type in _REGISTRY:
raise ValueError(f"Provider type '{provider_type}' already registered")
_REGISTRY[provider_type] = ProviderDescriptor(
provider_type=provider_type,
description=description,
cls=cls,
)
return cls
return decorator
def get_provider_class(provider_type: str) -> type[ChatProvider] | None:
"""按类型名查找已注册的 Provider 类。"""
descriptor = _REGISTRY.get(provider_type)
return descriptor.cls if descriptor else None
def create_provider(provider_type: str, **kwargs) -> ChatProvider:
"""工厂方法:按类型名创建 Provider 实例。"""
cls = get_provider_class(provider_type)
if cls is None:
raise ValueError(f"Unknown provider type: {provider_type}")
return cls(**kwargs)
def list_providers() -> list[ProviderDescriptor]:
"""返回所有已注册的 Provider 描述符。"""
return list(_REGISTRY.values())与 Phase 3 插件系统的衔接:当前注册表是模块级字典,Provider 模块通过
import触发@register_provider装饰器。Phase 3 的完整插件系统可通过 entry points 或 manifest 自动发现并 import Provider 模块,无需改动注册 API。
B.5 _ReasoningMixin — 共享推理提取逻辑
# nahida_bot/agent/providers/reasoning.py
import re
from enum import Enum
from typing import TYPE_CHECKING
if TYPE_CHECKING:
pass
class ReasoningPolicy(Enum):
"""推理内容注入上下文的策略。"""
STRIP = "strip" # 丢弃推理文本,仅保留签名(节省 token)
APPEND = "append" # 完整注入推理内容(最完整上下文)
BUDGET = "budget" # 预算允许时注入(推荐默认值)
# <think/> 标签正则(兼容 <think|thinking|thought> 标签名)
_THINK_TAG_PATTERN = re.compile(r"<think(?:ing)?\s*>(.*?)</think(?:ing)?\s*>", re.DOTALL)
def extract_think_tags(content: str) -> tuple[str, str | None]:
"""从 content 中提取并移除 <think/> 标签。
返回 (cleaned_content, extracted_reasoning)。
用于无结构化 reasoning 字段的 Provider 的兜底推理提取。
"""
if not content:
return content, None
matches = _THINK_TAG_PATTERN.findall(content)
if not matches:
return content, None
reasoning = "\n".join(match.strip() for match in matches if match.strip())
cleaned = _THINK_TAG_PATTERN.sub("", content).strip()
return cleaned, reasoning or None
class _ReasoningMixin:
"""OpenAI 兼容族共享的推理提取逻辑。"""
reasoning_key: str = "reasoning_content" # 响应中推理字段的键名
reasoning_output_mode: str = "native" # "native"(结构化字段)| "tagged"(<think/> 标签)
def _extract_reasoning_from_message(self, message: dict[str, object]) -> str | None:
"""从响应消息中提取推理内容。
优先级:(1) 结构化字段 (2) <think/> 标签兜底。
"""
# 优先级 1:native 字段
raw = message.get(self.reasoning_key)
if isinstance(raw, str) and raw.strip():
return raw
# 优先级 2:tag-based 提取
content = message.get("content")
if isinstance(content, str):
cleaned, reasoning = extract_think_tags(content)
if reasoning:
# 注意:需要同时更新 content(去掉 think 标签)
# 调用方负责处理 cleaned content
return reasoning
return Nonereasoning_key 的设计:不同 Provider 在 OpenAI 兼容格式中使用不同的推理字段名。DeepSeek 用 "reasoning_content",Groq 用 "reasoning"。子类只需覆盖 reasoning_key 属性即可,无需重写解析逻辑(借鉴 AstrBot 模式)。
B.6 推理链上下文策略
ReasoningPolicy 控制 ContextBuilder 在组装上下文时如何处理推理内容:
# nahida_bot/agent/context.py(ContextBudget 扩展)
@dataclass(slots=True, frozen=True)
class ContextBudget:
"""上下文预算设置。"""
max_tokens: int = 272000
reserved_tokens: int = 10000
auto_compact_token_limit: int | None = None
max_chars: int | None = None
reserved_chars: int = 0
summary_max_chars: int = 2000
# Phase 2.8 新增
reasoning_policy: ReasoningPolicy = ReasoningPolicy.BUDGET
max_reasoning_tokens: int = 2000 # 推理内容最大 token 数
@property
def usable_tokens(self) -> int:
... # 现有逻辑不变三种策略的行为:
| 策略 | 推理文本 | 推理签名 | 适用场景 |
|---|---|---|---|
STRIP | 丢弃 | 始终保留 | 纯聊天,节省 token |
APPEND | 完整注入 | 保留 | 调试、复杂推理任务 |
BUDGET | 预算内注入,超出则丢弃 | 始终保留 | 通用场景(推荐默认值) |
关键不变量:无论策略如何,reasoning_signature 始终保留在历史中。Anthropic 和 Gemini 的多轮对话依赖签名回传,丢弃签名会导致 API 报错。
B.7 工具 Schema 转换
format_tools() 方法在 ChatProvider 上提供默认 OpenAI 格式,各 Provider 族覆盖:
# Anthropic 族覆盖
def format_tools(self, tools: list[ToolDefinition]) -> list[object]:
return [
{
"name": tool.name,
"description": tool.description,
"input_schema": tool.parameters, # 注意:Anthropic 用 "input_schema" 而非 "parameters"
}
for tool in tools
]
# Gemini 族覆盖(未来)
def format_tools(self, tools: list[ToolDefinition]) -> list[object]:
# Gemini 需要清理不支持的 JSON Schema 关键字(default, additionalProperties 等)
# 确保 array 类型始终有 items schema
return [
{"function_declarations": [convert_schema_for_gemini(tool) for tool in tools]}
]B.8 历史回放(签名/Thinking 块在多轮对话中的回传)
每个 Provider 的 serialize_messages() 方法负责将 ContextMessage 中的 reasoning/reasoning_signature 转换为 Provider 原生格式:
OpenAI 兼容族:
# OpenAICompatibleProvider.serialize_messages
for msg in messages:
payload = {"role": msg.role, "content": msg.content}
if msg.role == "assistant" and msg.reasoning:
payload["reasoning_content"] = msg.reasoning
# ... tool_calls handling ...Anthropic 族:
# AnthropicProvider.serialize_messages
for msg in messages:
if msg.role == "assistant":
blocks: list[dict] = []
# 注入 thinking 块(如有签名)
if msg.reasoning_signature:
blocks.append({
"type": "thinking",
"thinking": msg.reasoning or "",
"signature": msg.reasoning_signature,
})
if msg.has_redacted_thinking:
blocks.append({
"type": "redacted_thinking",
"signature": msg.reasoning_signature,
})
if msg.content:
blocks.append({"type": "text", "text": msg.content})
# ... tool_use blocks from metadata ...Gemini 族(原生端点):
# GeminiProvider.serialize_messages(Phase 3)
# thought_signature 需 base64 解码后附加到对应 Part 上
for msg in messages:
if msg.role == "assistant" and msg.reasoning_signature:
part = types.Part(text=msg.reasoning or "", thought_signature=base64.b64decode(msg.reasoning_signature))C. 文件布局
nahida_bot/agent/providers/
__init__.py # ✅ 已更新:新增导出(AnthropicProvider, TokenUsage, ReasoningPolicy, registry 函数等)
base.py # ✅ 已扩展:TokenUsage, ProviderResponse 新字段, api_family, format_tools, serialize_messages
registry.py # ✅ 已新增:@register_provider, get_provider_class, create_provider, list_providers
reasoning.py # ✅ 已新增:extract_think_tags, _ReasoningMixin(ReasoningPolicy 定义在 context.py 以避免循环导入)
openai_compatible.py # ✅ 已演进:继承 _ReasoningMixin,填充新 ProviderResponse 字段,serialize_messages 注入推理
deepseek.py # ✅ 已新增:@register_provider("deepseek"),空子类
glm.py # ✅ 已新增:@register_provider("glm"),空子类
groq.py # ✅ 已新增:@register_provider("groq"),reasoning_key 覆盖 + 历史 strip
minimax.py # ✅ 已新增:@register_provider("minimax"),空子类
anthropic.py # ✅ 已新增:独立 AnthropicProvider(content blocks 解析 + 签名回传 + tool_use)
gemini.py # ☐ 未实现:独立 Provider(Phase 3)
errors.py # 不变
nahida_bot/agent/
context.py # ✅ 已扩展:ReasoningPolicy 枚举, ContextMessage 新字段, ContextBudget 新增 reasoning_policy/max_reasoning_tokens
loop.py # ✅ 已扩展:_build_assistant_message 传播推理/签名字段
tests/
test_reasoning.py # ✅ 14 tests: extract_think_tags, _ReasoningMixin, ReasoningPolicy
test_provider_registry.py # ✅ 7 tests: 注册表、查找、重复检测
test_provider_reasoning_integration.py # ✅ 12 tests: DeepSeek reasoning, think-tag fallback, Groq stripping, TokenUsage, refusal
test_provider_anthropic.py # ✅ 14 tests: text/thinking/redacted/tool_use blocks, system prompt, signature replay
integration/test_provider_multibackend_live.py # ✅ 10 tests: OpenAI/DeepSeek/Anthropic live roundtrips + contract validationD. 实施计划
Phase 2.8 ✅ — 已完成:
- ✅ 扩展
base.py:TokenUsagedataclass +ProviderResponse新字段 +ChatProvider新方法 - ✅ 扩展
context.py:ContextMessage新字段 +ContextBudget新字段 +ReasoningPolicy枚举 - ✅ 新建
reasoning.py:extract_think_tags()+_ReasoningMixin - ✅ 新建
registry.py:@register_provider+ 工厂方法 - ✅ 演进
openai_compatible.py:继承_ReasoningMixin,填充新字段,serialize_messages处理推理历史 - ✅ 新建空子类:
deepseek.py、glm.py、groq.py、minimax.py - ✅ 扩展
loop.py:_build_assistant_message传播推理/签名字段 - ✅ 编写完整测试套件
- ✅ 更新 architecture 文档
Phase 2.8b ✅ — 已完成:
- ✅ 新建
anthropic.py:独立 Anthropic Provider(content blocks 解析 + 签名回传 + tool_use) - ✅ Anthropic 单元测试(14 tests)
- ✅ 多后端集成测试(OpenAI/DeepSeek/Anthropic,通过
.env.test配置 API Key)
Phase 3+:
- 句子级/段落级增量输出 hook(建立在 provider 内部流式聚合基础上)
GeminiProvider原生端点实现- 更多后端适配器
- 完整插件系统(通过 entry points 发现 Provider 模块)
E. 当前实现状态(Phase 2.8 完成后)
本节记录 Phase 2.8/2.8b 完成后的实际代码状态,供后续开发参考。
设计决策与偏离:
ReasoningPolicy定义位置:architecture 文档原设计将其放在reasoning.py,实际放在context.py以避免循环导入(context.py→providers.reasoning→providers.__init__→base→context.py)。reasoning.py通过from nahida_bot.agent.context import ReasoningPolicy重新导出。_ReasoningMixin._extract_reasoning_from_message返回值:architecture 文档原设计返回str | None,实际返回tuple[str | None, str | None](reasoning_content, cleaned_content)。当 think-tag 被提取后,调用方需要使用 cleaned content 替换原始 content。GroqProvider.serialize_messages:不使用super()而是显式调用OpenAICompatibleProvider.serialize_messages(self, ...),因为@dataclass(slots=True)+ 多重继承导致super()的 MRO 解析失败。Anthropic
serialize_messages返回值:返回tuple[str | None, list[dict]]而非list[dict],因为 Anthropic 要求 system prompt 作为独立顶层参数传递。Reasoning 上下文策略尚未完全落地:
ContextBudget已有reasoning_policy和max_reasoning_tokens字段,AgentLoop._build_assistant_message()也会把 Provider 返回的 reasoning 写入ContextMessage;但当前ContextBuilder的预算估算、裁剪和SessionRunner._load_history()恢复路径没有实际保留/应用 reasoning、signature 或 metadata。文档中“上下文策略完整实现”的描述应视为目标状态,后续需要补齐实现和测试。
Provider 注册表状态(通过 list_providers() 可查):
| Provider Type | Class | API Family | Key Features |
|---|---|---|---|
openai-compatible | OpenAICompatibleProvider | openai-completions | Base class, _ReasoningMixin, think-tag fallback |
deepseek | DeepSeekProvider | openai-completions | Empty subclass, inherits reasoning_key |
glm | GLMProvider | openai-completions | Empty subclass |
groq | GroqProvider | openai-completions | reasoning_key="reasoning", strips reasoning from history |
minimax | MinimaxProvider | openai-completions | Empty subclass |
anthropic | AnthropicProvider | anthropic-messages | Independent impl, content blocks, signature passback |
集成测试环境:
.env.test.example # 模板文件,包含所有支持的 Provider 环境变量
.env.test # 实际密钥文件(gitignored),从 .env.test.example 复制测试用 fixtures(conftest.py):
live_llm_config→ OpenAI 兼容后端live_deepseek_config→ DeepSeek 后端live_anthropic_config→ Anthropic 后端
F. 图像理解与多模态上下文(规划)
目标:当当前 session 的主模型原生支持图片输入时,把图片作为结构化内容块直接传入模型;当主模型不支持图片输入时,提供 image_understand 能力,由配置的 vision fallback 模型生成图片描述,再把描述和图片引用转交给主模型。这个能力属于 Provider/Context 边界,不能只做成某个 Channel 的文本降级。
F.1 能力模型
新增显式能力声明,按“provider slot + 具体 model”解析,而不是只按 Provider 类型判断:
@dataclass(slots=True, frozen=True)
class ModelCapabilities:
text_input: bool = True
image_input: bool = False
tool_calling: bool = True
reasoning: bool = False
prompt_cache: bool = False
prompt_cache_images: bool = False
explicit_context_cache: bool = False
prompt_cache_min_tokens: int = 0
max_image_count: int = 0
max_image_bytes: int = 0
supported_image_mime_types: tuple[str, ...] = ("image/jpeg", "image/png", "image/webp")解析优先级:
- 显式配置优先:
providers.<id>.models[]中模型对象的capabilities覆盖所有推断;字符串模型名使用默认关闭能力。 - Provider 内置默认值:OpenAI/Anthropic/Gemini 等 Provider 可以按已知模型名前缀提供保守默认。
- 可选远端探测:如果某 Provider 支持 list models 或模型元数据,可补充能力,但不能覆盖显式配置。
- 未知能力默认关闭:未知模型不假设支持图片输入,避免把图片 URL/base64 发给不兼容接口。
ProviderSlot 应持有 capabilities_by_model,ProviderManager.resolve_model() 返回 slot 后,SessionRunner 使用实际 selected_model or slot.default_model 解析本轮能力。配置层不再有 provider 级 model 或 capabilities;默认模型就是 models 列表第一个元素。
缓存相关能力也必须显式建模。OpenAI prompt caching 是自动前缀缓存,图片、工具和完整 messages 都可能被缓存,但要求内容块和参数保持一致;Anthropic 支持 cache_control,缓存顺序为 tools -> system -> messages,图片/文档块可作为 user message content 缓存;Gemini 同时有隐式缓存和显式 cached content。不同 Provider 对“图片是否可缓存、是否需要显式 cache id、最小 token 阈值和 TTL”的语义不同,不能只用一个布尔值表示。
F.2 消息与上下文数据结构
当前 InboundMessage.text 只能承载纯文本,Milky 图片会降级成 [Media: type=image, resource_id=...]。规划新增第一类入站媒体对象,并保留文本降级作为可读 fallback:
@dataclass(slots=True, frozen=True)
class InboundAttachment:
kind: str # image, audio, video, file
platform_id: str # resource_id/file_id/message scoped id
url: str = "" # temp URL, may expire
path: str = "" # local cached path, if downloaded
mime_type: str = ""
file_size: int = 0
width: int = 0
height: int = 0
alt_text: str = "" # platform summary or generated description
metadata: dict[str, object] = field(default_factory=dict)InboundMessage 增加 attachments: list[InboundAttachment] = field(default_factory=list)。ContextMessage 保持 content: str 兼容旧代码,同时增加 parts: list[ContextPart]:
@dataclass(slots=True, frozen=True)
class ContextPart:
type: str # text, image_url, image_base64, image_description
text: str = ""
url: str = ""
data: str = "" # base64 only after size/mime validation
mime_type: str = ""
media_id: str = ""Provider 的 serialize_messages() 负责把 ContextPart 转成各自格式:
- OpenAI 兼容 vision:
content: [{"type":"text"}, {"type":"image_url"}] - Anthropic:
content: [{"type":"text"}, {"type":"image", "source": ...}] - Gemini 原生:
parts: [text, inline_data/file_data]
F.3 主路径:原生多模态
当本轮主模型 image_input=True:
- Channel converter 解析图片段,生成
InboundAttachment(kind="image"),优先带path,没有本地缓存时带临时 URL。 SessionRunner在调用AgentLoop.run()前把用户文本和图片附件组装成ContextMessage(parts=[text, image...])。ContextBuilder预算时同时估算文本 token、图片预算和缓存收益;超过max_image_count时优先保留最近且可稳定重放的图片,其余降级为描述或平台摘要。- Provider 序列化时只发送经过校验的 URL/base64/path,不把原始
raw_event直接透传给模型。 MemoryStore持久化用户回合时保存文本、媒体引用 metadata、缓存键和已生成描述;默认不保存原始 base64。
F.4 Fallback 路径:image_understand
当本轮主模型 image_input=False 且消息含图片:
SessionRunner注入一个内置工具定义image_understand,并在用户消息中保留稳定占位符,例如[Image: media_id=..., resource_id=..., summary=...]。- 默认策略建议为
image_fallback_mode: auto:首轮进入主模型前,先用 fallback vision 模型生成简洁图片描述,作为同一用户回合的image_descriptionpart 注入。这样用户只发“这是什么?”时不依赖非视觉主模型自行决定调用工具。 - 同时保留工具模式:
image_fallback_mode: tool时,主模型可按需调用image_understand(media_id, question?);工具从当前 session 的媒体缓存中读取图片,调用 fallback Provider,然后把结构化结果回填给主模型。 - 工具输出包含
media_id、description、detected_text、safety_notes、model、created_at,并缓存到本轮媒体 metadata,供多轮复用。 - 如果 fallback 模型失败,主模型仍收到平台摘要和错误可读说明,不能让整个对话中断。
配置建议:
multimodal:
image_fallback_mode: auto # auto / tool / off
image_fallback_provider: vision
image_fallback_model: gpt-5.2
max_images_per_turn: 4
max_image_bytes: 10485760
providers:
vision:
type: openai-compatible
api_key: "${VISION_API_KEY}"
base_url: "${VISION_BASE_URL}"
models:
- name: "gpt-5.2"
capabilities:
image_input: true
tool_calling: falseF.5 多轮上下文策略
- 短期窗口:最近 N 轮内、URL 未过期且预算允许时,可继续保留原生图片 part;如果 Provider 支持图片 prompt cache,并且图片内容块可稳定重放,应优先保留而不是立刻压成描述。
- 长期历史:只保留
media_id、平台资源 id、尺寸、mime、hash、描述和时间戳。过期 URL 不进入 Provider 请求。 - 描述复用:同一图片 hash + 同一 fallback prompt 复用描述,避免多轮重复调用 vision 模型。
- 用户追问:如果用户后续说“刚才那张图”,上下文里至少有最近图片的
media_id和描述;若主模型是 vision 且本地缓存仍在,可重新附图,否则使用已缓存描述。 - 裁剪规则:默认裁剪优先级从低到高为旧且不可缓存的图片原始内容、旧图片描述、旧文本摘要、最近且可缓存的图片原始内容、最近用户文本。图片签名/资源 id 不能伪装成可访问内容,必须带可用性状态。
F.5b KV / Prompt Cache 策略
这里的“KV cache”指 Provider 在 prefill 阶段对 prompt 前缀产生的 key/value tensors 或等价内部表示做复用;对 API 用户暴露时通常叫 prompt caching 或 context caching。它不是 Nahida 本地可直接读写的会话状态,但我们的上下文构造会显著影响命中率。
关键约束:
- 缓存通常匹配前缀:稳定内容应放在前面,变化内容放在后面。系统 prompt、工具 schema、workspace 指令、长期摘要、已确认稳定的媒体块,应尽量保持顺序和序列化字节稳定。
- 图片可能被缓存:OpenAI 和 Anthropic 都允许图片内容块参与缓存;Gemini 的显式 cached content 也适合长媒体或文档。删除图片可以减少本轮输入,但会失去后续对同一图片追问时的图片缓存收益。
- 动态 URL 会破坏缓存:如果每轮重新获取不同
temp_url,即使图片实际相同,也会表现为不同内容块。对支持图片缓存的 Provider,短期重放应优先使用稳定的本地缓存 hash/base64 或 Provider 文件/cache id,而不是每轮刷新平台临时 URL。 - base64 不是默认长期存储:base64 可提供稳定内容块,但会放大请求体和本地存储风险。默认只在短期内从
MediaCache重新生成,并受max_image_bytes、TTL 和日志脱敏限制。 - 显式缓存优先于重复内联:Gemini cached content、Anthropic
cache_control、OpenAIprompt_cache_key/retention 这类 Provider 特性应通过 Provider 层暴露;一旦有显式 cache id,就在后续请求中引用 cache id,而不是长期重复内联图片。
默认策略建议设为 media_context_policy: cache_aware:
| 场景 | 策略 |
|---|---|
| 主模型不支持 vision | 不保留原图给主模型;保存 fallback 描述和媒体 metadata |
| 主模型支持 vision,图片刚进入最近 N 轮 | 保留原生图片 part,并保持内容块序列化稳定 |
| 主模型支持 vision,图片被连续追问 | 优先复用 Provider 显式 cache id;否则复用稳定本地缓存生成的同一 image part |
| 图片 URL 已过期且无本地缓存 | 降级为已生成描述和 unavailable 状态 |
| 图片很旧、很大或超过预算 | 保留描述、hash、尺寸和 media_id,移除原生图片 part |
需要新增指标:
- Provider 返回的
cached_tokens/cache_read_input_tokens/cached_content_token_count等缓存命中字段。 - 每轮媒体 part 数量、图片 token/字节估算、被原生保留/被描述替代/不可用的原因。
- fallback vision 调用次数、命中描述缓存次数、显式 Provider cache id 命中次数。
官方参考:
- OpenAI Prompt Caching:
https://platform.openai.com/docs/guides/prompt-caching - Anthropic Prompt Caching:
https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching - Gemini Context Caching:
https://ai.google.dev/gemini-api/docs/caching
F.6 Channel 与资源安全
Milky/Telegram 等 Channel 只负责把平台媒体标准化,不直接决定是否送入模型。媒体处理需要统一组件:
MediaResolver:把resource_id、Telegram file id 或 URL 解析成可访问资源。MediaCache:按 session/media_id/hash 管理本地缓存、TTL、大小限制和清理。MediaPolicy:限制 MIME、文件大小、像素数、下载域名、重定向和 SSRF 风险。- 日志脱敏:不记录带 access token 的临时 URL,不把 base64 写入日志或 SQLite。
F.7 测试要求
ProviderManager:模型能力显式配置、默认推断、未知模型默认无 vision。ContextBuilder:混合 text/image parts 的预算、裁剪和摘要降级。- Provider 序列化:OpenAI vision、Anthropic image blocks、非 vision Provider 拒绝原生图片 part。
SessionRunner:vision 主模型走原生图片;非 vision 主模型触发 auto fallback;tool模式注册并执行image_understand。- Prompt/cache 行为:稳定图片 part 在连续追问中保持相同序列化;动态
temp_url不应替代可用的稳定缓存引用;Provider usage 中的缓存字段被记录。 - Channel converter:Milky image segment 和 Telegram photo/document 生成
InboundAttachment,原始事件仍保留。 - Memory:持久化/恢复媒体 metadata 和描述,不持久化 base64,不恢复过期 URL。
- 安全:超大文件、错误 MIME、过期资源、下载失败、fallback Provider 失败均有可解释降级。
G. 流式响应
当前实现采用“provider 内部流式接收、聚合后返回”的策略:ChatProvider.chat() 的公共返回值仍是 ProviderResponse,AgentLoop 和 Channel 不直接消费 token 级增量。这样可以在长推理/重任务期间持续从上游读取 SSE 事件,减少普通 HTTP read timeout 把“服务端仍在计算”误判为 ProviderTimeoutError 的概率,同时不改变聊天机器人现有的发送节奏。
配置入口为 provider 条目的 stream_responses: true。支持范围:
| Provider 族 | 流式机制 | 推理增量 |
|---|---|---|
| OpenAI-compatible(含 DeepSeek/GLM/Groq) | /chat/completions SSE,聚合 choices[0].delta | delta.reasoning_content 或 provider 自定义 reasoning key |
| Anthropic-compatible(含 Minimax) | Messages API SSE,聚合 message_start / content_block_delta / message_delta | thinking_delta + signature_delta |
| OpenAI Responses | /responses SSE,聚合 response.output_text.* / response.output_item.done / response.completed | reasoning summary output item |
| Gemini 原生 | 尚未实现 | thought parts 增量 |
后续如果要做句子级或段落级提前发送,应在 provider 流式聚合路径旁边增加 chunk hook/observer,而不是改变 ChatProvider.chat() 的基础契约。AgentLoop 可继续在 tool calling 场景下发送已得到的完整消息;Channel 是否支持编辑消息或分句发送应作为独立能力处理。
H. 测试要求
# === TokenUsage ===
def test_token_usage_total_sums_input_output()
# === ProviderResponse ===
def test_provider_response_frozen_with_new_fields()
def test_provider_response_extra_default_empty_dict()
# === extract_think_tags ===
def test_extract_think_tags_extracts_think_content()
def test_extract_think_tags_handles_no_tags()
def test_extract_think_tags_handles_empty_content()
def test_extract_think_tags_strips_from_content()
# === _ReasoningMixin ===
def test_reasoning_mixin_extracts_native_field()
def test_reasoning_mixin_falls_back_to_tags()
def test_reasoning_mixin_respects_custom_reasoning_key()
# === Provider Registry ===
def test_register_provider_registers_class()
def test_register_provider_rejects_duplicate()
def test_create_provider_instantiates_correct_class()
def test_list_providers_returns_all()
# === OpenAICompatibleProvider ===
def test_openai_extracts_standard_content()
def test_openai_extracts_tool_calls()
def test_openai_extracts_refusal()
def test_openai_extracts_reasoning_tokens()
def test_openai_extracts_reasoning_via_key()
def test_openai_extracts_reasoning_via_tags_fallback()
def test_openai_serialize_messages_injects_reasoning_history()
# === DeepSeekProvider ===
def test_deepseek_extracts_reasoning_content()
def test_deepseek_inherits_openai_base()
# === GLMProvider ===
def test_glm_inherits_openai_base()
# === GroqProvider ===
def test_groq_uses_reasoning_key()
def test_groq_strips_reasoning_from_history()
# === AnthropicProvider(Phase 2.8b)===
def test_anthropic_extracts_text_blocks()
def test_anthropic_extracts_thinking_blocks()
def test_anthropic_extracts_tool_use_blocks()
def test_anthropic_handles_redacted_thinking()
def test_anthropic_handles_interleaved_thinking()
def test_anthropic_extracts_signature()
def test_anthropic_serializes_thinking_replay()
def test_anthropic_format_tools_uses_input_schema()
# === ContextBuilder 推理策略 ===
def test_context_builder_strip_policy_drops_text_keeps_signature()
def test_context_builder_append_policy_includes_all()
def test_context_builder_budget_policy_includes_when_room()
def test_context_builder_budget_policy_strips_when_over()
def test_context_builder_always_preserves_signature()
# === 集成测试 ===
def test_agent_loop_propagates_reasoning_to_context_message()
def test_agent_loop_propagates_signature_to_context_message()
def test_multi_turn_preserves_signature_across_turns()