LLM 集成
Provider 抽象、提示词缓存、模型路由
Minara Agent 通过统一的内部接口对接多个 LLM 服务商。 在 Anthropic、OpenAI、OpenRouter 或本地模型之间切换, 无需改动 Agent 循环。 本页介绍 provider 层的结构、提示词缓存的组织方式,以及模型路由的工作原理。
为什么用自定义路由器,而不直接调用各厂商 SDK? Agent 循环、回测、反思、子 Agent 等功能对成本和延迟的要求各不相同。 Minara 的路由器可以在同一代码库中,将需要稳定缓存的高成本任务路由到 Sonnet, 将低成本批量评估路由到 Haiku;当某个 provider 触发限流时也能优雅降级。 直接绑定单一厂商 SDK,会将你锁定在其定价体系中。
实际使用:通过环境变量
(ANTHROPIC_API_KEY、OPENAI_API_KEY、OPENROUTER_API_KEY)配置 provider。
Provider 接口
所有 provider 均实现
apps/agent/src/core/agent-loop.ts
中的 LLMClient:
interface LLMClient {
createMessage(params: {
model: string;
system: SystemPrompt; // string 或可缓存块
messages: MessageParam[];
tools?: ToolDefinition[];
maxTokens?: number;
stream?: boolean;
}): Promise<LLMResponse>;
streamMessage?(params: ...): AsyncIterable<LLMStreamEvent>;
vision?(call: LLMVisionCall): Promise<LLMVisionResult>;
}具体实现位于
apps/agent/src/llm/:
| 文件 | Provider | 鉴权方式 |
|---|---|---|
anthropic-api-key.ts | Anthropic | ANTHROPIC_API_KEY |
anthropic-oauth.ts | Anthropic(Claude.ai) | OAuth refresh token |
anthropic-wire.ts | 共享 wire 协议 | |
openai-wire.ts | OpenAI / OpenRouter | OPENAI_API_KEY / OPENROUTER_API_KEY |
openrouter.ts | OpenRouter 路由器 | OPENROUTER_API_KEY |
select-provider.ts
在启动时按优先级顺序,根据环境变量选择具体客户端:
- Anthropic OAuth(
CLAUDE_CODE_OAUTH_TOKEN已设置时) - Anthropic API key(
ANTHROPIC_API_KEY已设置时) - OpenRouter(
OPENROUTER_API_KEY已设置时) - OpenAI(
OPENAI_API_KEY已设置时)
四项均未配置时,进程将拒绝启动并输出明确报错信息。
选择逻辑在 app.ts 中只有一行;下游全部通过 LLMClient 通信。
提示词缓存:不可省略
Anthropic 提示词缓存的 TTL 为 5 分钟,命中缓存时费用仅为正常输入 token 价格的 10%。 对于每轮都会发起大量相似调用的 Agent(每次工具调用往返都需要 catalog 加 identity), 缓存命中率决定了成本是"可接受"还是"过于昂贵"。
因此,系统提示词被拆分为若干显式块:
system: [
{ type: "text", text: identityPrompt, cache: true },
{ type: "text", text: skillCatalog, cache: true },
{ type: "text", text: activeSkillPrompts, cache: false },
{ type: "text", text: signalContextBlock, cache: false },
{ type: "text", text: pendingConfirmation, cache: false },
]提示词构建器强制执行以下不变式:
- 可缓存块靠前排列。 非可缓存块之后的内容无法缓存,因为缓存键采用严格的前缀匹配。
- 可缓存块保持稳定。 identity 和 catalog 仅在技能注册表或基础提示词变更时才会更新,不会按轮次变化。
- 动态内容置于末尾。 活跃技能片段、信号上下文、待确认项以及对话历史,均放在缓存边界之后。
anthropic-wire.ts
将 SystemPromptBlock[] 映射为 Anthropic 的
cache_control: {type: "ephemeral"} 标记,
并在响应元数据中追踪缓存命中率;
结构化日志中对应字段为
llm.cache_read_input_tokens 和 llm.cache_creation_input_tokens。
对于不支持提示词缓存的 provider(如撰写本文时的 OpenAI), wire 层会静默地将所有块拼接为单一系统字符串。 调用方无感知,行为也不会产生差异。
模型选择
每轮使用的模型由
apps/agent/src/learning/llm-router.ts
决定(注意:此路由器与技能路由器无关,专门负责将 LLM 调用路由到具体模型)。
默认策略如下:
- 主 Agent 轮次:Claude Sonnet 4.6,上下文窗口 1M。
- 深度研究子 Agent:Claude Opus 4.6(高推理能力)。
- 视觉调用:选用支持视觉能力的 provider。
- 向量嵌入:通过专用环境变量
EMBEDDING_PROVIDER指定。
每次调用都会将 {provider, model, reason} 写入审计日志,
可直接用 SQL 查询回答"为什么这轮花费了 X",无需逐层排查代码。
覆盖方式:
AGENT_MODEL固定主 Agent 循环使用的模型。 也可通过minara config set model.defaultModel <id>或/model斜杠命令交互式设置, 两者均会持久化到$MINARA_DATA_DIR/env。
工具调用格式
Anthropic 和 OpenAI 均支持工具调用,但 wire 格式不同。
Agent 循环内部采用 Anthropic 的格式,
openai-wire.ts 负责双向转换。
具体影响如下:
- 工具 schema 只定义一次,以 JSON Schema 形式存于
ToolEntry.schema.parameters,两个 wire 层共用同一份 schema。 - 停止原因已归一化。 Anthropic 的
"end_turn"/"tool_use"/"max_tokens"与 OpenAI 的"stop"/"tool_calls"/"length"均映射为循环消费的统一枚举值。 - 流式事件已归一化为统一的
LLMStreamEvent联合类型,/chat/stream无需感知上游 provider。
视觉能力
视觉调用通过独立的 LLMClient.vision() 方法处理,
因为并非所有 provider 都支持在工具调用中内联视觉能力。
vision_analyze 工具会显式调用该方法;
需要读取截图的工具(browser.* 工具集)会先将图片编码为 base64 再发起调用。
Anthropic OAuth 流程
Anthropic OAuth 允许用户使用 Claude.ai 账号登录,无需提供 API key。
相关流程位于
apps/agent/src/llm/oauth/:
/auth/anthropic/init启动 PKCE 流程,返回授权 URL。- 用户访问该 URL,授权后被重定向回应用。
/auth/anthropic/exchange用授权码换取 refresh token。- Refresh token 使用本地密钥加密后存入 SQLite。
- 每次 LLM 调用在需要时会自动透明地刷新 access token。
OpenAI 和 OpenRouter 采用相同模式。 具体路由请参阅鉴权接口参考文档。
新增 Provider
接入新的 LLM provider:
- 在
apps/agent/src/llm/<name>-wire.ts中实现LLMClient。 提示词缓存支持是可选项,但强烈建议实现。 - 在
select-provider.ts中添加对应的选择分支。 - 将环境变量添加到
.env.example及 环境变量清单。 - 在
tests/integration/llm/下编写集成测试, 使用tests/fakes/llm/中的 wire 层 fake。
不要修改 Agent 循环来添加 provider 专属代码路径。 若某个 provider 有特殊行为,应将其封装在 wire 层内部处理。