Web 搜索与抓取

模型访问互联网走两条有界的 seam:搜索返回带标题与 URL 的命中列表,抓取把页面取回并分类成可读的文本。 对应实现:src/Tether.Web/(seam)、src/Tether.Web.Http/(providers)与 src/Tether.Web.Tools/(工具)。

seam 与确定性选择

public interface IWebService
{
    IDisposable RegisterSearchProvider(IWebSearchProvider provider);
    IDisposable RegisterFetchProvider(IWebFetchProvider provider);
    ValueTask<WebSearchResult> SearchAsync(WebSearchRequest request, CancellationToken ct = default);
    ValueTask<WebFetchResult> FetchAsync(string url, CancellationToken ct = default);
}

Provider 选择是确定性的:按配置的偏好顺序(SearchPreference / FetchPreference),同 id 以字典序破平—— 永远不看注册顺序。注册同一个 id 是替换而非叠加,返回的 IDisposable 只摘除自己那份登记。

工具是否启用与 provider 是否可用是两件事:IsAvailableAsync(例如凭据此刻能否解析出来)只影响运行期选择, 工具本身始终注册着,不可用时以稳定码失败而不是让工具神秘消失。

内置搜索 provider

Tether.Web.Http 带三个 HTTP provider;凭据在每次请求前重新解析,因此轮换后的下一次请求立即生效:

  • Exa、Perplexity —— 各家 wire 形状封在各自 provider 内;
  • DeepSeek(provider id deepseek-official)——单 query provider 私有地调用 Anthropic Messages POST /messages,默认 base https://api.deepseek.com/anthropic/v1、model deepseek-v4-flash、max_tokens: 4096,并携带 { type: "web_search_20250305", name: "web_search", max_uses: 5 } native server tool。请求同时发送 x-api-key、Authorization: Bearer 与 anthropic-version: 2023-06-01;这些 secret/header 不进入事件或快照。

先 durable,再派发

每次 DeepSeek dispatch 都先 append 并 flush web/deepseek-search-llm-request,记录 provider/model、 exact messages、native tool config、query/source attribution、credential reference 与所有非 secret resolved options。 只有持久化成功后才允许发网络请求;失败或取消不会留下逃逸的 model-visible auxiliary input。该契约只依赖 Coordinator 提供的 ISessionPersistence;composition 在 JSONL 与 SQLite 之间替换物理 ISessionEventStore 时,这条 consumer 侧 durability fence 不变。

响应只接受真实 structured web_search_tool_result。命中按 URL 去重,title / url / page_age 来自 result item, snippet 从 text block 的 citation 按 URL 关联;malformed shape、HTTP error、provider refusal、partial result 与 abrupt EOF 都归一成稳定的 WEB_PROVIDER,调用方取消仍原样传播且永不返回 partial success。

请求发出之后的这些失败还会带上 endpoint 恢复指引:消息尾部追加已解析的 Messages endpoint 绝对 URI、 “搜索 endpoint 与 chat 模型配置相互独立”的说明、承载它的 web-search-deepseek 组合行 baseUrl 配置项, 以及”只有用户可以选择或更改 endpoint”。非 2xx 响应体里的 error.message / error / message detail 是追加在 HTTP 状态描述之后,读不出来时只损失更丰富的消息、不改变失败本身。凭据缺失与取消发生在 dispatch 之前或之外, 不带指引。指引同时存在于 WebException.Guidance 结构化字段里:Tether.Web.Tools 仍然把 provider 原始消息 替换成稳定文本(远端错误体不进入模型可见输出),但会把这段 secret-free 指引一起交给模型。

抓取侧 HttpFetchProvider 是纯 HttpClient 实现:重定向、字节数、字符数、时间四重上限(默认最多 5 次重定向), 取回的 body 分类成 WebFetchKind——可解码为文本则给出 Text,二进制以 WEB_BINARY 拒绝。

有界多查询搜索

web_search 工具保留 #27 对齐的 bounded multi-query 语义,接受 1–4 个 query 的数组而不是单字符串:

  1. 归一化。 去重、去空白;超上限直接参数错误,不静默截断。
  2. 并发扇出。 每个 query 独立成一次 WebSearchRequest 并发执行,整体有超时;任一失败立即 abort 其余——部分结果不凑数。
  3. round-robin 合并。 各 query 的命中按轮询交错合并、按 URL 去重,总结果封顶(默认 8 条)——避免第一个 query 的好结果把后面的全挤掉。
  4. 有界答案。 provider 附带的 answer 摘要截断到 4 000 字符。

搜索结果要求模型在回复里内联引用 URL;多查询的形状让模型一次调用覆盖一个话题的多个侧面,而不是串行发四次请求。

稳定失败码

码含义
WEB_NO_PROVIDER没有任何可用 provider
WEB_BAD_URL / WEB_HTTP_STATUSURL 非法 / 上游非成功状态
WEB_TOO_MANY_REDIRECTS / WEB_TOO_LARGE抓取越限
WEB_BINARYbody 不是可分类文本
WEB_TIMEOUT超时
WEB_MISSING_CREDENTIALprovider 需要但解析不到凭据
WEB_PROVIDERprovider 侧其它失败

下一步

在 GitHub 上编辑此页