# Web 工具与服务注入

> FetchURL 与 WebSearch 的「工具定接口、服务挑实现」模式，「无实现即不暴露」在 v2 里的新写法，以及加工内容的如实标注。

- 作者：David（道雾轩）
- 专栏：Kimi Code 源码解读（https://daiw.org/manual/kimi-code.md）
- 最后更新：2026-09-19
- 原文：https://daiw.org/manual/kimi-code/web-tools
- 转载与引用：请注明出处并附原文链接（https://daiw.org/about/copyright）

`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 保留了这层接口：

```ts
export interface UrlFetcher {
  fetch(
    url: string,
    options?: { toolCallId?: string; signal?: AbortSignal },
  ): Promise<UrlFetchResult>;
}
```

变的是注入方式：`FetchURLTool` 只依赖 DI 服务 `IWebFetchService`，由 App 作用域的 `WebFetchService` 按顺序挑实现：

```ts
  getUrlFetcher(): UrlFetcher {
    return this.fromServicesConfig() ?? this.fromManagedOAuth() ?? this.localFetcher;
  }
```

1. `services` 配置段里配了 `moonshotFetch` → 用配置的抓取服务；
2. 用 Kimi 账号登录（托管 OAuth）→ 用 Kimi 的 `/fetch` 接口；
3. 都没有 → 本机的 `LocalFetchURLProvider`。

`WebSearchTool` 同理，依赖 `IWebSearchProviderService`，来源只有「配置的搜索服务」（`moonshotSearch`）和「托管 OAuth」两种，没有本地兜底。

这样设计的理由没变：网络访问的策略——代理、白名单、配额、审计——**因部署而异**。CLI 可能直接出网，[KAP 服务端](https://daiw.org/manual/kimi-code/kap-server)可能要走公司代理。工具代码不替任何一方做决定，这和 [Kaos](https://daiw.org/manual/kimi-code/kaos) 以及 v2 自己的 `os/` 抽象层是同一个手法：**把「会因环境而变」的部分抽成接口**。

## 本地兜底也有边界

`app/web/providers/local-fetch-url.ts` 不是一个裸 `fetch()`：

- 只接受 `http` / `https`；私有地址、回环地址直接拒绝，域名解析到私有地址也拒绝，并把连接钉在校验过的地址上（配置了代理时除外）；
- 手动跟随重定向，每一跳重新校验，最多 10 跳；
- 响应体超过 10 MB 拒绝。

托管服务那条路失败时（非用户中止），`MoonshotFetchURLProvider` 会记一条遥测，再退回本地实现。

## 无实现即不暴露

v0.29 的原则是「没有注入 fetcher，就不注册这个工具，不暴露给 LLM」。v2 把这条原则写在了注册处的 `when` 谓词上：

```ts
registerAgentToolService(IWebSearchTool, WebSearchTool, {
  name: 'WebSearch',
  domain: 'auth',
  when: (accessor) => accessor.get(IWebSearchProviderService).hasWebSearchProvider(),
});
```

没有搜索服务时，工具激活阶段根本不会把 `WebSearch` 放进工具表。`FetchURL` 没有 `when`——它永远有本地实现，所以总是暴露。万一搜索服务在会话中途被移除，工具返回明确的错误（「the provider was removed after this session started」），而不是含糊地失败。

<Callout type="warn">
  **这条原则值得单独记住，因为违反它的代价比看上去大。**

  模型看到工具就会尝试用。如果你注册了一个必然失败的 `WebSearch`，模型会：调用它 → 收到错误 → 换个参数再试 → 再失败 → 也许改用 `bash curl` 绕道。**一次失败的工具调用不只浪费一个回合，它还会污染上下文、诱导模型走弯路。**

  正确做法是让模型**根本不知道**这个能力存在——它自然会用别的方式解决问题，或者直接告诉用户做不到。
</Callout>

这条原则在 Kimi Code 里是一致的：[MCP 服务器](https://daiw.org/manual/kimi-code/mcp)连不上时，它的工具不在列表里；被工具策略禁用的工具也不会出现在发给模型的列表里，而不是进了列表、调用即报错。

## 加工过的内容要说明

v0.29 用一个类型说明「返回内容与原始响应体的关系」；v2 把它落成了明确的枚举：

```ts
export type UrlFetchKind = 'passthrough' | 'extracted';
```

本地实现遇到 `text/plain`、`text/markdown` 原样返回（`passthrough`），其余用 Readability 提取正文（`extracted`）；托管服务返回的一律标 `extracted`。`FetchURLTool` 把区别写进结果开头：

```ts
      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 万字符预算，截断部分落盘并告诉模型（见[工具契约](https://daiw.org/manual/kimi-code/tool-contract)）——用意一致：**凡是对内容做过手脚，就得如实告诉模型做过什么。**

顺带一提：两个工具都在默认放行名单里，`accesses` 是 `ToolAccesses.none()`，可以和任何工具并行；审批规则按 URL 或查询词做 glob 匹配，比如 `FetchURL(https://example.com/*)`。

## 小结

两个小工具，带出三条通用原则：

1. **会因环境而变的能力，工具只依赖接口**，实现交给服务按配置挑选。
2. **没有实现就不要暴露**——v2 用 `when` 谓词在激活阶段落实。
3. **加工过的内容必须标注**——`passthrough` / `extracted` 写进结果开头。

下一篇：[权限与审批](https://daiw.org/manual/kimi-code/permission)。
