Web 工具与服务注入
FetchURL 与 WebSearch 的「工具定接口、服务挑实现」模式,「无实现即不暴露」在 v2 里的新写法,以及加工内容的如实标注。
agent-core-v2/src/agent/tools/ 下的 fetch-url/ 与 web-search/ 只有几个小文件,代码量很小,但它们体现的架构模式贯穿整个 Kimi Code。
工具定接口,服务挑实现
v0.29 的 fetch-url.ts 文件头写着:「kimi-core defines the interface; the host provides the real fetch implementation via UrlFetcher」。v2 保留了这层接口:
export interface UrlFetcher {
fetch(
url: string,
options?: { toolCallId?: string; signal?: AbortSignal },
): Promise<UrlFetchResult>;
}变的是注入方式:FetchURLTool 只依赖 DI 服务 IWebFetchService,由 App 作用域的 WebFetchService 按顺序挑实现:
getUrlFetcher(): UrlFetcher {
return this.fromServicesConfig() ?? this.fromManagedOAuth() ?? this.localFetcher;
}services配置段里配了moonshotFetch→ 用配置的抓取服务;- 用 Kimi 账号登录(托管 OAuth)→ 用 Kimi 的
/fetch接口; - 都没有 → 本机的
LocalFetchURLProvider。
WebSearchTool 同理,依赖 IWebSearchProviderService,来源只有「配置的搜索服务」(moonshotSearch)和「托管 OAuth」两种,没有本地兜底。
这样设计的理由没变:网络访问的策略——代理、白名单、配额、审计——因部署而异。CLI 可能直接出网,KAP 服务端可能要走公司代理。工具代码不替任何一方做决定,这和 Kaos 以及 v2 自己的 os/ 抽象层是同一个手法:把「会因环境而变」的部分抽成接口。
本地兜底也有边界
app/web/providers/local-fetch-url.ts 不是一个裸 fetch():
- 只接受
http/https;私有地址、回环地址直接拒绝,域名解析到私有地址也拒绝,并把连接钉在校验过的地址上(配置了代理时除外); - 手动跟随重定向,每一跳重新校验,最多 10 跳;
- 响应体超过 10 MB 拒绝。
托管服务那条路失败时(非用户中止),MoonshotFetchURLProvider 会记一条遥测,再退回本地实现。
无实现即不暴露
v0.29 的原则是「没有注入 fetcher,就不注册这个工具,不暴露给 LLM」。v2 把这条原则写在了注册处的 when 谓词上:
registerAgentToolService(IWebSearchTool, WebSearchTool, {
name: 'WebSearch',
domain: 'auth',
when: (accessor) => accessor.get(IWebSearchProviderService).hasWebSearchProvider(),
});没有搜索服务时,工具激活阶段根本不会把 WebSearch 放进工具表。FetchURL 没有 when——它永远有本地实现,所以总是暴露。万一搜索服务在会话中途被移除,工具返回明确的错误(「the provider was removed after this session started」),而不是含糊地失败。
这条原则值得单独记住,因为违反它的代价比看上去大。
模型看到工具就会尝试用。如果你注册了一个必然失败的 WebSearch,模型会:调用它 → 收到错误 → 换个参数再试 → 再失败 → 也许改用 bash curl 绕道。一次失败的工具调用不只浪费一个回合,它还会污染上下文、诱导模型走弯路。
正确做法是让模型根本不知道这个能力存在——它自然会用别的方式解决问题,或者直接告诉用户做不到。
这条原则在 Kimi Code 里是一致的:MCP 服务器连不上时,它的工具不在列表里;被工具策略禁用的工具也不会出现在发给模型的列表里,而不是进了列表、调用即报错。
加工过的内容要说明
v0.29 用一个类型说明「返回内容与原始响应体的关系」;v2 把它落成了明确的枚举:
export type UrlFetchKind = 'passthrough' | 'extracted';本地实现遇到 text/plain、text/markdown 原样返回(passthrough),其余用 Readability 提取正文(extracted);托管服务返回的一律标 extracted。FetchURLTool 把区别写进结果开头:
const note =
kind === 'passthrough'
? 'The returned content is the full response body, returned verbatim.'
: 'The returned content is the main text extracted from the page.';这不是多余的严谨。抓回来的网页几乎不可能原样喂给模型,模型必须知道它拿到的是加工过的版本,否则会把「页面里没有这段文字」当成事实依据,而实际上只是被提取器丢掉了。长页面还要经过通用的 5 万字符预算,截断部分落盘并告诉模型(见工具契约)——用意一致:凡是对内容做过手脚,就得如实告诉模型做过什么。
顺带一提:两个工具都在默认放行名单里,accesses 是 ToolAccesses.none(),可以和任何工具并行;审批规则按 URL 或查询词做 glob 匹配,比如 FetchURL(https://example.com/*)。
小结
两个小工具,带出三条通用原则:
- 会因环境而变的能力,工具只依赖接口,实现交给服务按配置挑选。
- 没有实现就不要暴露——v2 用
when谓词在激活阶段落实。 - 加工过的内容必须标注——
passthrough/extracted写进结果开头。
下一篇:权限与审批。