数据截至 (上游 commit 676a0a228882)
DeepSeek Provider 与扩展面(MCP/Skills/Plugins)
30 秒导读: 一个 AI agent 有两条边界。一条朝模型:怎么把用户的历史消息发给 DeepSeek、怎么把它吐回来的 token 流接住并翻译成 Whale 内部能用的事件。另一条朝外部世界:怎么让第三方给 Whale 加新工具、新技能、新行为。这章讲透这两条边界——DeepSeek Provider 和四个扩展口(MCP / Skills / Plugins / Hooks)。
本章处在整套讲解的"下游"和"侧翼"。回合循环(见 01-turn-loop.md)是主干,提示词缓存(见 02-prompt-cache-memory.md)是招牌,工具系统(见 03-tools-and-edit.md)是手脚。这一章讲的是:手脚从哪来(扩展面) 以及 主干靠什么驱动(Provider 事件)。
1. 这是什么(零基础也能懂)
1.1 两条边界,一个比喻
把 Whale 想成一家餐厅的后厨总管:
- 朝厨师那条边界(Provider):总管要跟主厨(DeepSeek 大模型)沟通——把订单(对话历史)递过去,再把主厨一句一句报出来的菜名(流式 token)接住、听懂、转成后厨看板上的条目。主厨有时报到一半噎住了(网络断流),总管得决定重报还是放弃。
- 朝 供应商那条边界(扩展面):总管不可能什么都自己做。需要新食材、新设备时,他对接外部供应商(MCP server / Skills / Plugins),把它们的能力"挂进"后厨。供应商有 1000 种设备目录,但总管不会把整本目录堆在操作台上——要用哪个,现查现取。
1.2 它解决什么问题
| 边界 | 要解决的问题 |
|---|---|
| Provider | 大模型的 HTTP/SSE 协议、流式解析、断流重试、推理内容、多模态附件——这些又脏又杂的活,不能污染 agent 主循环 |
| 扩展面 | agent 内置工具永远不够用;要让用户/第三方安全地加工具、加技能、加生命周期钩子,而不改 Whale 源码 |
1.3 一句话直觉
Provider 是"翻译官 + 收发室": 把 Whale 的统一消息模型翻成 DeepSeek 的 JSON,把 DeepSeek 的 SSE token 流翻回 Whale 的
ProviderEvent。扩展面是"插座": MCP、Skills、Plugins、Hooks 是四种不同规格的插座,外部能力插上就能用;其中 MCP 这个插座还带一个"目录检索开关"(
tool_search),不用把上千个工具一次性通电。
本节不出现代码。目标:知道这一章在讲哪两件事。
2. 顶层全 景(它大概怎么转)
2.1 一张图:两条边界
先说怎么读这张图:中间是 Whale 主循环,左边是它跟 DeepSeek 的对话(Provider),右边是四种扩展口。数据流是双向的——历史出去,事件回来;工具挂进来,调用出去。
┌───────────────────────────────────────┐
│ Whale 回合循环 (agent) │
│ (见 01-turn-loop:主线在这) │
└───────────────────────────────────────┘
历史 msgs ↑↓ ProviderEvent 工具目录 ↑↓ 工具调用
┌───────────────────────────┐ ┌──────────────────────────────────┐
│ Provider 边界 (朝模型) │ │ 扩展面 (朝外部世界) │
│ │ │ │
│ DeepSeek Client │ │ ① MCP stdio/http server │
│ ├ StreamResponse (SSE) │ │ └ 1000+ 工具 → tool_search │
│ ├ StreamRespWithPrefix │ │ 延迟检索(不全塞上下文) │
│ │ (/beta 前缀补全) │ │ ② Skills SKILL.md 说明书 │
│ ├ streamWithRetries │ │ (只加载、不执行) │
│ │ (重试 + 错误分类) │ │ ③ Plugins 运行时装能力 │
│ └ multimodal (图/PDF/音) │ │ (tools/skills/hooks/mcp...) │
│ │ │ ④ Hooks 生命周期钩子 │
│ ↓ HTTPS │ │ (PreToolUse/Stop/...) │
│ api.deepseek.com │ │ │
└───────────────────────────┘ └──────────────────────────────────┘
2.2 部件一句话职责
| 部件 | 干什么 | 在哪个文件 |
|---|---|---|
deepseek.Client | 跟 DeepSeek 对话的全部逻辑:请求组装、SSE 解析、重试 | internal/llm/deepseek/client.go:35 |
ProviderEvent / ProviderResponse | Provider 与主循环之间的统一事件契约 | internal/llm/provider.go:52 |
retry.Policy | 与 Provider 无关的通用重试策略(退避、Retry-After) | internal/llm/retry/retry.go:16 |
MCP Manager | 连接 MCP server、发现工具、按名调用 | internal/mcp/manager.go:34 |
DeferredToolCatalog | 上千 MCP 工具的可搜索目录(不进上下文) | internal/mcp/deferred.go:26 |
tool_search 工具 | 模型按需检索并"激活"延迟工具 | internal/tools/catalog_mcp.go:47 |
skills 包 | 发现/解析本地 SKILL.md 说明书 | internal/skills/skills.go:27 |
plugins.Manager | 运行时聚合插件贡献的各类能力 | internal/plugins/plugin.go |
HookRunner | 在生命周期各点跑用户配置的钩子 | internal/agent/hooks.go:357 |
2.3 主线走一遍(高层)
一个回合里,这两条边界这样协作:
- 主循环把
[]core.Message历史交给Client.StreamResponse。 - Client 把历史翻成 DeepSeek 的 messages JSON,POST 到
/chat/completions,拿到 SSE 流。 - Client 边读边把每个 delta 翻成
ProviderEvent(内容/推理/工具参数/完成…)喂回主循环。 - 主循环发现模型要调工具。若是 MCP 延迟工具,先前模型已用
tool_search把它激活;调用经 MCPManager转发到对应 server。 - 工具执行前后,
HookRunner在PreToolUse/PostToolUse等点插入用户钩子。
Provider 事件如何逐帧驱动主循环,是 01-turn-loop.md 的内容;这里只讲事件怎么产生。
3. Provider:怎么跟 DeepSeek 对话
这一节由浅入深讲清朝模型那条边界。
3.1 统一契约:ProviderEvent
要解决的小问题: 主循环不该知道"DeepSeek 的 SSE 帧长什么样"。它只想收到一串语义清晰的事件。
Whale 的做法是定义一套与厂商无关的事件类型,任何 Provider 都产出它们:
| 事件 | 含义 |
|---|---|
EventContentDelta | 又来一小段可见回答文本 |
EventReasoningDelta | 又来一小段"思考"内容(reasoning) |
EventToolArgsDelta | 某个工具调用的参数又流来一段 |
EventToolUseStart / EventToolUseStop | 工具调用开始 / 全部结束 |
EventRetryScheduled | 断流了,已排定重试 |
EventComplete | 本轮结束,带完整 ProviderResponse |
EventError | 出错 |
事件类型定义在 internal/llm/provider.go:15,ProviderEvent 结构体在 provider.go:52,收尾的 ProviderResponse(含内容、工具调用、Usage)在 provider.go:63。
Provider 的接口本身极窄——只有一个方法:
// internal/llm/provider.go:72 # 真实源码
type Provider interface {
StreamResponse(ctx context.Context, history []core.Message, tools []core.Tool) <-chan ProviderEvent
}
返回一个事件 channel,主循环 range 它即可。可选的 PrefixCompletionProvider(provider.go:76)多一个前缀补全方法,DeepSeek 客户端两个都实现。
3.2 Option 家族:怎么配置一个 Client
思路: 用函数式选项(functional options)。New(opts ...Option) 逐个应用,默认值来自 defaults 包。
| Option | 作用 | 定义 |
|---|---|---|
WithModel | 选模型 | client.go:70 |
WithReasoningEffort | 推理力度(low/medium/high…) | client.go:78 |
WithThinking | 是否开启"思考"模式 | client.go:82 |
WithMaxTokens | 输出上限 | client.go:86 |
WithPrefixCompletion | 允许隐式前缀补全(见 3.4) | client.go:90 |
WithMultimodal | 配置多模态旁路(见 3.6) | client.go:94 |
WithThinking 一开,请求体里 thinking 从 {"type":"disabled"} 翻成 {"type":"enabled"},并带上 reasoning_effort(client.go:215-220)。这时 DeepSeek 会额外流回 reasoning_content,被翻成 EventReasoningDelta。
3.3 核心:SSE 流式解析
要解决的小问题: DeepSeek 用 Server-Sent Events(SSE,服务器逐行推送的长连接)一段段吐 token。要把这条字节流,实时切成一个个 ProviderEvent。
思路,分三层:
StreamResponse(client.go:180)起一个 goroutine,把错误也包成事件塞进 channel。parseSSE(client.go:575)逐行读,遇到data:行就攒着,遇到空行就把攒的一帧交给parseSSEData。parseSSEData(client.go:691)解一帧 JSON,按 delta 里有什么,发对应事件。
原理演示(把核心想法演出来):
# 示意,非源码:SSE 帧 → 事件
acc = Accumulator() # 累加器:攒完整内容 / 工具参数
for frame in sse_frames(response): # 逐帧
delta = frame["choices"][0]["delta"]
if delta.get("content"):
acc.content += delta["content"]
emit(ContentDelta(delta["content"])) # 可见文本
if delta.get("reasoning_content"):
emit(ReasoningDelta(delta["reasoning_content"])) # 思考
for tc in delta.get("tool_calls", []):
acc.tool_args[tc.index] += tc.function.arguments # 工具参数分片流来
emit(ToolArgsDelta(...))
if frame["choices"][0]["finish_reason"] == "tool_calls":
emit(Complete(acc.build())) # 重点看:工具调用参数是"流式拼起来"的
关键细节:
- 工具名归一化。 模型可能报
Bash/Read或它自己发明的别名,进入注册表前统一过core.CanonicalToolName(client.go:764)。 - 工具参数边流边判完整。 每来一段就试着
json.Unmarshal,一旦成为合法 JSON 就标记 ready(looksLikeCompleteJSON,client.go:948),让 UI 能尽早知道"这个工具调用凑齐了"。 - 收尾产出 Usage。
emitComplete(client.go:799)把prompt_cache_hit_tokens等缓存计数、推理重放、工具结果压缩节省等,全打进llm.Usage——这正是 02-prompt-cache-memory.md 观测缓存命中率的数据来源。
3.4 招牌配套:前缀补全(prefix completion)
要解决的小问题: 有时 Whale 想让模型接着一段已经写好的开头往下续——比如 Plan 模式收尾时,已有半句计划,要模型补完。普通 chat 接口做不到"你从这里接着写"。
思路: DeepSeek 的 /beta 端点支持在 messages 末尾塞一条特殊的 assistant 消息,标 prefix: true,模型就会把它当"已经说出口的话"续写下去。
StreamResponseWithPrefix(client.go:191)→ streamPrefix(client.go:236)干这件事,核心两步:
// internal/llm/deepseek/client.go:249 # 真实源码:把前缀塞回去
msgs = append(msgs, map[string]any{
"role": "assistant",
"content": prefix,
"prefix": true,
})
而端点必须切到 /beta,这由 prefixCompletionBaseURL(client.go:319)决定:官方 base URL 或其 /v1 会被改写成 defaultBaseURL + "/beta";非 DeepSeek 端点则回退成普通 stream(client.go:244-247)。补完后 joinPrefixCompletionContent(client.go:333)保证最终内容带上前缀、不重复。
为什么这事关缓存: 前缀补全让 Whale 能"续写"而不是"重开一段",从而保住前缀不变——这正是提示词缓存高命中的前提。前缀补全服务缓存的完整链条,见 02-prompt-cache-memory.md;这里只讲它的机制。 主循环侧的触发条件(opts.PrefixCompletion 且无工具时才走前缀 provider)在 internal/agent/stream_ingest.go:46。
一个易错点值得记:显式传入非空 prefix 时,无视 prefixCompletionEnabled 自动开关——调用方传了 prefix 就是直接选择;那个开关只管"隐式"使用(client.go:237-243 注释)。
3.5 重试与错误分类
这是 Provider 里最见功力的部分:网络会抖、模型偶尔吐空,得分清"重试有用"和"重试白费"。
两层重试。 streamWithRetriesAuth(client.go:344)有两个独立计数:
| 阶段 | 计数 | 说明 |
|---|---|---|
| 发请求 | requestAttempt | 连不上/收到可重试 HTTP 码,走通用 retry.Policy |
| 读流 | streamAttempt |