跳到主要内容

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 / ProviderResponseProvider 与主循环之间的统一事件契约internal/llm/provider.go:52
retry.Policy与 Provider 无关的通用重试策略(退避、Retry-After)internal/llm/retry/retry.go:16
MCP Manager连接 MCP server、发现工具、按名调用internal/mcp/manager.go:35
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 主线走一遍(高层)

一个回合里,这两条边界这样协作:

  1. 主循环把 []core.Message 历史交给 Client.StreamResponse
  2. Client 把历史翻成 DeepSeek 的 messages JSON,POST 到 /chat/completions,拿到 SSE 流。
  3. Client 边读边把每个 delta 翻成 ProviderEvent(内容/推理/工具参数/完成…)喂回主循环。
  4. 主循环发现模型要调工具。若是 MCP 延迟工具,先前模型已用 tool_search 把它激活;调用经 MCP Manager 转发到对应 server。
  5. 工具执行前后,HookRunnerPreToolUse / 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

思路,分三层:

  1. StreamResponse(client.go:180)起一个 goroutine,把错误也包成事件塞进 channel。
  2. parseSSE(client.go:575)逐行读,遇到 data: 行就攒着,遇到空行就把攒的一帧交给 parseSSEData
  3. 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已连上但流中途断,上限 defaultStreamMaxAttempts = 6(client.go:25)

通用重试策略在 internal/llm/retry/retry.go:DefaultPolicy(retry.go:52)默认 4 次、指数退避、抖动 0.1;ShouldRetry(retry.go:110)只对 429/500/502/503/504 和网络错重试;Backoff(retry.go:125)优先尊重服务器给的 Retry-After(甚至从响应体里正则抠 retry_after_ms,retry.go:197)。

流错误的三分类,是精华所在。shouldRetryStreamError(client.go:474)要判断一次断流该不该重来:

错误类型何时产生可重试?
streamTerminalError{retryable:true}模型只思考、没吐正文也没工具调用(reasoning-only 空补全)✅ 是
streamTerminalError{retryable:false}彻底空补全(连思考都没有)——多半是坏流❌ 否
streamProgressError已经吐出过内容才断——重来会重复输出❌ 否
streamStallError空闲超过 defaultStreamIdleTimeout = 90s(client.go:26)无输出✅ 是(除非 ctx 取消)

为什么 reasoning-only 空补全可以安全重发?emitComplete(client.go:810-814)里解释得很清楚:模型思考了但没产出可用内容,没有为输出 token 计费,是 DeepSeek 侧采样波动,重发一次通常就好了;而且 stream-retry 路径会通过 StreamReset 事件清掉已累积的推理/文本,重发不会重复。这个错误被 llm.ErrEmptyCompletion(provider.go:13)包裹,让上层不依赖包内私有类型也能识别。

3.6 多模态旁路

要解决的小问题: DeepSeek 主聊天模型不吃图片。但用户会贴图/PDF/音频。

思路: 检测到最新用户消息带附件(latestUserMessageHasAttachments,multimodal.go:17),就改走一条 OpenAI 兼容的旁路(streamMultimodal,multimodal.go:43)——用单独配置的多模态端点/模型,把附件编码成 OpenAI 格式的 content 分块。图片转 image_url data URL,PDF/文件转 file,音频转 input_audio(encodeOpenAIAttachment,multimodal.go:207)。这条旁路默认关闭,要在配置里显式 enabled = true(multimodal.go:82)。


4. 扩展面①:MCP 与延迟工具搜索

MCP(Model Context Protocol,模型上下文协议)是一个开放标准:第三方进程(server)通过它暴露一批工具,agent 连上去就能调。这是 Whale 最重的扩展口。

4.1 连接一个 MCP server

Manager.Initialize(manager.go:94)并发启动配置里的每个 server。每个 server 走 startServer(manager.go:217):

  1. createTransport(manager.go:389)按传输类型建连接——两种:
    • stdio:启一个子进程,用它的标准输入输出通信(sdk.CommandTransport)。
    • http:连一个 URL 端点(StreamableClientTransport),可带鉴权头。
  2. session.ListTools 拉取该 server 的工具清单。
  3. 每个工具记成 discoveredTool,附上允许目录、workspace 根等。

传输类型的判定在 config.go:87transportKind:显式 type/transport 优先,否则有 url 当 http、有 command 当 stdio。

调用时,Manager.CallTool(manager.go:205)按 server 名找到会话,带超时转发 CallTool

4.2 工具命名:全局唯一且合法

要解决的小问题: 不同 server 可能有同名工具;工具名还得符合模型 API 的字符与长度限制(≤64)。

QualifyToolName(names.go:17)把 (server, tool) 拼成 mcp__<server>__<tool>,非法字符清洗、超长则截断加短哈希。UniqueToolName(names.go:46)再解决碰撞:重名就补哈希后缀。反向解析用 ParseQualifiedToolName(names.go:30)。

4.3 招牌机制:tool_search 延迟工具搜索

要解决的小问题: 一个用户可能挂了十几个 MCP server,合计 1000+ 工具。如果把每个工具的完整 schema 都塞进模型上下文,光工具定义就撑爆窗口、且每回合都得重发——直接打死提示词缓存。

思路(呼应缓存目标): 不把工具全塞进去,只塞一份轻量目录索引;模型需要哪个,用一个特殊工具 tool_search 现查、现"激活"。

怎么读下面这张流程图:从上到下是一次按需取用,左边是模型看到的东西,右边是幕后目录。

模型上下文里只有: 幕后(不进上下文):
┌────────────────────────┐ ┌──────────────────────────┐
│ <available-deferred- │ │ DeferredToolCatalog │
│ tools> 每个 server │ ← 一行摘要 │ 1000+ 条 {name,server, │
│ 下列工具名 + 一句描述 │ │ description} │
│ + 一个 tool_search 工具 │ └──────────────────────────┘
└────────────────────────┘ ↑ Search(query)
│ 模型:"我需要 github 搜代码" │
│ 调用 tool_search("+github search") │
▼ │
catalog.Search → 最多 5 条命中 ─────────────────┘
│ promote(names)

BuildTools:把这几个工具的完整 schema 加进注册表


返回"已激活 N 个工具",后续回合可直接调

目录只存轻量元数据。 DeferredToolMeta(deferred.go:19)每条只有名字、server、一行描述;DeferredToolCatalog(deferred.go:26)是不可变、可搜索的集合,还算了内容哈希用于版本校验。

三种查询语法。 Search(deferred.go:97)支持:

语法含义实现
select:NameA,NameB按精确限定名取selectSearch deferred.go:117
+must_have extras名字必须含 must_have,extras 加分排序mustHaveSearch deferred.go:140
keyword phrase名字/描述上的大小写不敏感正则regexSearch deferred.go:184

三者都最多返回 5 条(maxSearchResults = 5,deferred.go:13),按相关性排序——名字命中(2 分)高于描述命中(1 分)。正则若语法非法,自动降级成字面量匹配(compileCatalogRegex,deferred.go:217)。

激活 = promote。 tool_search 工具(NewToolSearchTool,catalog_mcp.go:47)拿到命中名字后调 promote,后者经 Manager.BuildTools(manager.go:340)按同一套确定性排序重建这几个工具的完整对象、加进注册表,并把 schema 渲染回给模型(catalog_mcp.go:154-166)。之后这些工具就"在你的工具 schema 里"了。

为什么这保护缓存: 目录索引是稳定前缀的一部分,基本不变;激活发生在对话后段,不动前面——契合 02-prompt-cache-memory.md 讲的"把变化推到上下文末尾"。给模型看的清单由 RenderAvailableDeferredTools(deferred.go:245)渲染成 <available-deferred-tools> 块,按 server 分组。

app 层怎么接: refreshMCPTools(internal/app/mcp_runtime.go:45)在 MCP 初始化后 BuildDeferredCatalog,通过 mcpCatalogAdapter(mcp_runtime.go:69)把 mcp 包的目录适配成 tools 包要的接口;激活过的工具还会被持久化,重启后 RestorePromotedTools 恢复(mcp_runtime.go:34)。


5. 扩展面②③④:Skills / Plugins / Hooks

5.1 Skills:只加载、不执行的说明书

它是什么: 一个 Skill 就是一份 SKILL.md——带 frontmatter(名字、描述、可选 requires)加一段自然语言指令。它教模型"遇到某类任务该怎么做",本身不是可执行代码

发现: skills 包扫描一组根目录——工作区的 .whale/skills.agents/skills,以及 home 下同名目录(DefaultRoots,skills.go:109)。每个 Skill(skills.go:27)解析出 Instructions;Requires(skills.go:38)可声明依赖的命令/环境变量/MCP,用于标注"需要 setup"。

加载: 模型调 loadSkill 工具(internal/tools/skills.go:12)时,Whale 校验名字合法、未被禁用、存在,然后把说明书正文(必要时截断)连同 setup 状态返回。返回体里明确写着 "execution": "not_executed" 和用法提示:"这个工具只加载技能说明,不执行脚本、不改文件"(tools/skills.go:80-81)。安全边界很清楚——skill 是给模型看的提示,不是自动执行的动作。这与 04-safety-permissions.md 的权限观一致。

5.2 Plugins:运行时装配的能力包

它是什么: 插件是能一次性贡献多种能力的包。internal/plugins/ 共 7 个文件,核心是 plugin.go(接口与 Manager)、local.go(本地安装/加载)、local_components.go(组件解析),外加两个内置插件目录 memoryplugin/skillsimprover/

能力是"按接口选配"的。 一个插件实现哪些 provider 接口,就贡献哪类能力(plugin.go:108 起):

接口贡献
ToolProvider额外工具
SkillProvider额外技能
MCPProvider额外 MCP server 配置
HookProvider / CommandHookProvider生命周期钩子
SlashCommandProvider斜杠命令
StartupContextProvider启动时注入的上下文

plugins.Manager(plugin.go:291)加载所有启用插件后,用 Tools()/Skills()/MCPServers()/Hooks()(plugin.go:519 起)把各家贡献聚合起来,交给主程序。每个插件在 manifest 里声明 CapabilitiesPermissions(plugin.go:20-40)——比如要读写工作区、跑后台任务,都是显式授权。

5.3 Hooks:生命周期钩子

它是什么: 在 Whale 运行的关键节点插入用户自定义逻辑。事件清单在 HookEvents(internal/agent/hooks.go:53):

事件触发点
PreToolUse / PostToolUse工具执行前 / 后
PermissionRequest请求权限时
PreCompact / PostCompact上下文压缩前 / 后
SessionStart / UserPromptSubmit会话开始 / 用户提交
SubagentStart / SubagentStop子代理起 / 止(见 05-subagents-workflows.md)
StopWhale 结束本回合前

四种钩子类型,一套决策模型。 钩子可以是 command(跑 shell)、http(POST 到 URL)、promptagent(runResolvedHook,hooks.go:574)。跑完得到一个 HookDecision(hooks.go:264):pass / warn / block / halt

关键在于谁能拦。 isBlockingEvent(hooks.go:1428)规定只有 PreToolUsePermissionRequestUserPromptSubmitSubagentStart 这几个"事前"事件能真正 block;事后事件即使返回 block 也被降级成 warn(normalizeHookDecision,hooks.go:1334)。命令钩子的退出码约定很简洁:

// internal/agent/hooks.go:1432 # 真实源码:退出码 → 决策
func decideHookOutcome(event HookEvent, res HookSpawnResult) HookDecision {
if res.SpawnErr != nil { return HookDecisionError }
if res.TimedOut { return HookDecisionTimeout }
if res.ExitCode == 0 { return HookDecisionPass }
if res.ExitCode == 2 && isBlockingEvent(event) { return HookDecisionBlock } // 退出码 2 = 拦截
return HookDecisionWarn
}

信任模型。 钩子来自 .whale/config.toml 等文件(LoadHooks,hooks.go:826)。项目级钩子首次出现是 Untrusted,要显式信任;内容一改哈希变了就回到 Modified,需重新信任(hookTrustForEntry,hooks.go:1035)。这防止仓库里夹带的钩子未经用户同意就跑——与 04-safety-permissions.md 的信任边界呼应。


6. 巧妙之处(可带走的技术)

  • 窄接口 + 事件 channel 解耦 Provider。 Provider 只有一个 StreamResponse 方法返回 channel(provider.go:72)——换任何模型厂商,主循环一行不用改。可选能力(前缀补全)用第二个接口表达(provider.go:76),按类型断言探测,避免污染主接口。
  • 空补全的"可重试"判定基于计费语义。 区分 reasoning-only(没为输出计费,重发安全)与彻底空(坏流,重发无益),不是拍脑袋,而是从"有没有产出、有没有计费"推导(client.go:810shouldRetryStreamError client.go:474)。
  • 延迟工具:目录进上下文、schema 不进。 用一份 5 条上限的可搜索索引 + 一个 tool_search 工具,把 1000+ 工具的上下文成本压到近乎常量,还保住了缓存前缀(deferred.go:97catalog_mcp.go:47)。
  • 前缀补全走 /beta 且能优雅回退。 只有官方端点改写到 /beta,别的端点自动退回普通流(client.go:319244),扩展能力不牺牲兼容性。
  • 重试延迟会读服务器意图。 不只指数退避,还解析 Retry-After 头、甚至从错误响应体正则抠出建议等待(retry.go:173197),对齐服务端限流。
  • 钩子拦截权限按事件收窄。 只有事前事件能真拦,事后 block 降级为 warn(hooks.go:1334),避免"事已成再拦"的语义混乱。

7. 边界与局限(诚实)

  • 单一厂商实现。 仓库里 llm/ 下只有 deepseek 一个具体 Provider;接口虽通用,但目前没有第二家实现来印证抽象是否够宽(基于本次所读代码)。
  • 多模态是旁路、默认关闭。 主聊天模型不吃图,附件必须配一个独立的 OpenAI 兼容端点才行(multimodal.go:82);未配就直接报错。
  • 前缀补全依赖 DeepSeek /beta 非 DeepSeek base URL 一律回退成普通流,拿不到"续写已有 assistant 前缀"这个能力(client.go:319)。
  • MCP 工具全靠 server 存活。 CallTool 要求会话已连接,server 崩了调用即失败(manager.go:209);npx/npm 启动的 stdio server 还有"下载/占用 stdio 导致启动超时"的坑,代码专门给了提示(startupHint,manager.go:775)。
  • Skill 只加载不执行。 想让 skill 里的脚本真正跑起来,得靠模型再去调别的工具;loadSkill 本身绝不执行(tools/skills.go:80)。

8. 代码地图(导航索引)

用符号名 grep 比行号更抗漂移(上游更新后行号会变)。

主题文件符号
DeepSeek 客户端与选项internal/llm/deepseek/client.goClientNewWithModelWithThinkingWithPrefixCompletionWithMultimodal
SSE 流式解析internal/llm/deepseek/client.goStreamResponseparseSSEparseSSEDataemitComplete
前缀补全internal/llm/deepseek/client.goStreamResponseWithPrefixstreamPrefixprefixCompletionBaseURLjoinPrefixCompletionContent
重试与流错误分类internal/llm/deepseek/client.gostreamWithRetriesAuthshouldRetryStreamErrorstreamTerminalErrorstreamStallErrorstreamProgressError
通用重试策略internal/llm/retry/retry.goPolicyDefaultPolicyShouldRetryBackoffretryAfterDelay
Provider 契约internal/llm/provider.goProviderPrefixCompletionProviderProviderEventProviderResponseUsageErrEmptyCompletion
多模态旁路internal/llm/deepseek/multimodal.gostreamMultimodaltoOpenAICompatibleMessagesencodeOpenAIAttachment
MCP 连接与调用internal/mcp/manager.goManagerInitializestartServercreateTransportCallToolBuildDeferredCatalogBuildTools
MCP 命名internal/mcp/names.goQualifyToolNameParseQualifiedToolNameUniqueToolName
延迟工具目录internal/mcp/deferred.goDeferredToolCatalogSearchselectSearchmustHaveSearchregexSearchRenderAvailableDeferredTools
tool_search 工具internal/tools/catalog_mcp.goNewToolSearchToolDeferredToolCatalog(接口)、DeferredToolPromoter
app 层接线internal/app/mcp_runtime.gorefreshMCPToolsmcpCatalogAdaptermakeDeferredPromoter;internal/app/provider.go newDeepSeekProvider
Skillsinternal/skills/skills.go / internal/tools/skills.goSkillDefaultRootsRequires;loadSkill
Pluginsinternal/plugins/plugin.go / local.goPluginManagerToolProviderMCPProviderHookProvider
Hooksinternal/agent/hooks.goHookEventsHookRunnerRunHookWithObserverdecideHookOutcomeisBlockingEventhookTrustForEntry