关于 Search that shows its work
search 是一个可自托管的网页搜索 MCP 服务,适合需要核验搜索结果来源与可靠性的 Agent。它使用自有元搜索实例,并将问题、引擎覆盖和读取异常等情况放入可分支处理的字段。
主要功能
- 01搜索网页并读取靠前页面
- 02读取网页、PDF 和扫描件
- 03图像搜索与页面截图
- 04多轮深度搜索并汇总来源
- 05返回可检查的异常与覆盖信息
适用场景
- 01核验搜索结果是否来自多个引擎
- 02读取 JavaScript 渲染页面或 PDF
- 03通过截图和页面文本交叉检查
- 04汇集多来源资料并标示未确认内容
MCP 相关
提供网页搜索、读取、图像搜索、截图和深度搜索等 MCP 工具。可通过远程 HTTP 或 stdio 传输连接;搜索依赖配置的元搜索服务。
配置
本地服务的配置在各客户端中通用,复制后粘贴到客户端的 MCP 配置文件即可。
我们还没有获取到这个服务的安装包或远程地址,请按项目 README 中的说明安装。
工具
本地服务需要在你的电脑上运行后才能读取工具,维护者暂未提供工具列表。
详细介绍
搜索工具可能以看似成功的方式失败。搜索引擎返回了别人的主题。网页返回的是反机器人屏蔽页面。16 个来源实际上是两个搜索引擎各算了 8 次。这些情况都不会报错,而另一端的大模型却会基于这些结果继续生成内容。
**这个服务的作用,就是让这些情况可以区分,并通过可用于分支判断的字段呈现。**它运行在你的机器上,使用你自己的元搜索实例和模型密钥——也可以完全不使用模型密钥。
🔧 工具
| 工具 | 功能 | 必填项 | 重要选项 |
|---|---|---|---|
web_search | 查找网页并读取排名靠前的网页——一次调用即可获取带有对应文本的链接 | query | read: false 仅返回链接 · read_top 指定读取数量 · min_engines 强制要求搜索结果覆盖一定数量的搜索引擎 · engines 自行指定搜索引擎 |
web_read | 按地址读取网页、PDF,或由视觉模型识别的扫描件 | urls | mode: browser 用于 JS 渲染的网页 · expect 用于断言必须包含的内容 · offset 用于继续读取 |
web_image_search | 查找图片:分别返回图片文件的地址和图片所在页面的地址 | query | max_results、page |
web_screenshot | 获取网页的 PNG 截图以及同一次访问中提取的文本,以便交叉核对 | url | max_chars 用于指定文本长度 · full_page · expect |
web_deep_search | 自动组织查询,分轮读取,并综合多个来源作答,同时说明无法确认的内容 | question | waves |
完整的参数参考、响应格式和失败模式:HOWTO-CALL.md。
📦 安装
从打开仓库页面到获得可用答案。无需预先在磁盘上准备任何内容:
git clone https://github.com/AG-Bureau/mcp-search
cd mcp-search
cp .env.example .env
echo "SEARXNG_SECRET=$(openssl rand -hex 32)" >> .env
echo "BROWSER_WS_SECRET=$(openssl rand -hex 16)" >> .env
echo '{}' > door-tokens.json && mkdir -p tls
docker compose -f docker-compose.yml -f wiring/expose-localhost.yml up -d --build
curl -s http://127.0.0.1:8081/healthz
第 4 行和第 5 行不是装饰。SEARXNG_SECRET 没有值时,up 命令会拒绝运行——这是有意为之:元搜索没有自己的密钥时不会失败,而是会静默使用其镜像模板中公开已知的密钥启动。BROWSER_WS_SECRET 是浏览器 sidecar 端点的秘密路径,缺少它时 up 也会拒绝运行:如果路径可以被猜到,任何能与 sidecar 建立 WebSocket 连接的对象——compose 网络中的任何容器,以及浏览器打开的任何页面——都能在模块对其读取的页面实施的所有检查之外,拥有自己的浏览器。compose 文件会将同一个值传给 sidecar,并附加在 BROWSER_WS_URL 的末尾传给适配器;两者都不会打印该值。
第 6 行会创建 compose 文件用于挂载的两个路径,分别用于调用方身份验证和 TLS——默认都处于关闭状态。Docker 会将不存在的路径创建为由 root 拥有的目录;而作为目录读取的令牌“文件”会变成无法读取的目录列表(见下文配置)。
两者都在 .gitignore 中:开始使用后,它们会存放密钥。
此 overlay 仅在 loopback 接口上发布端口。已发布的容器端口不会经过主机防火墙的常规规则链,因此若要扩大对外暴露范围,必须另行明确操作——参见部署与对外暴露。
两种传输方式
MCP 有两种传输方式,它们解决的是不同的问题。HTTP——上面的命令—— 适用于已经在某处运行的服务。stdio 是该协议的默认方式:客户端将服务作为进程启动,并通过管道与其通信,这也是大多数桌面客户端和封装程序的工作方式。
docker compose exec -T ag-search python /app/server.py --stdio # or MCP_TRANSPORT=stdio
# outside compose: SEARXNG_URL=http://<a metasearch you can reach> python adapter/server.py --stdio
每行输入一个 JSON-RPC 对象,每行输出一个应答。模式需要显式选择,绝不会根据是否连接了终端来猜测——这个标志只是位于主题旁边,总有一天它会替一个无人打算处理的情况作答。
在 stdio 模式下,stdout 就是协议:只能输出应答,日志则写入 stderr。多出一行任何其他内容,都会导致读取它的客户端出错。
每个以此方式启动的进程都是独立实例,即使它们运行在同一个容器内也是如此:它们的浏览器会话(BROWSER_MAX_SESSIONS)、引擎、域名请求间隔控制以及 /stats 计数器都与 HTTP 服务相互独立。同一侧车容器上的多个 stdio 客户端,会像多个适配器一样共同占用其 pids_limit。
侧车容器不受此选择影响,但元搜索会:SEARXNG_URL 必须能够访问一个元搜索服务,否则所有搜索都会因 cause: upstream 而被拒绝(默认值 http://searxng:8080 只是 compose 网络内可识别的名称)。如果客户端在没有 compose 项目运行的情况下启动该模块,模块会指出其他缺失项,而不会假装一切正常:浏览器路径会报告 not_wired_up,trouble 会包含 pool_unmeasured——引擎池从未根据观测结果计算过。
⬆️ 升级到 0.3.5——无需新增变量
从 0.3.4 升级:使用与安装区块相同的命令和 -f 文件。只需更改适配器镜像(ag-mod-search/adapter:0.3.5,供适配器和探测器使用);侧车容器仍为 ag-mod-search/browser:1.49.1-3。你可能会注意到两项变化:入口现在会为每个请求记录一行日志(调用方地址、路径、状态和调用方的令牌名称——绝不记录令牌、请求正文或查询字符串);模型调用若超出调用方分配的时间,现在会返回说明原因的拒绝响应,而不是继续运行。从 0.3.3 或更早版本升级,也需要执行下方 0.3.4 的步骤。
⬆️ 升级到 0.3.4——无需新增变量,但有一项变量的要求更严格
从 0.3.3 升级:使用与安装区块相同的命令和 -f 文件。只需更改适配器镜像;侧车容器仍为 ag-mod-search/browser:1.49.1-3。先检查 AG_DOOR_AUTH:现在,如果其值不是 off、warn 或 on(不区分大小写,且会忽略两侧空格),适配器将无法启动;之前这类值会被视为 off。从 0.3.2 或更早版本升级,也需要执行下方 0.3.3 的步骤。
⬆️ 升级到 0.3.3——需要新增一项必填变量
运行 up 之前,.env 中必须包含 BROWSER_WS_SECRET:按照安装区块中的做法,使用 openssl rand -hex 16 添加该变量。然后运行安装区块中的命令,并使用安装时相同的 -f 文件:
docker compose -f docker-compose.yml -f wiring/expose-localhost.yml up -d --build。
此命令会重建两个镜像并重新创建适配器、探测器和侧车容器,因为它们各自的镜像或环境都发生了变化。不要在命令中指定服务名称:up … browser ag-search 会让探测器继续使用旧镜像;省略 -f 覆盖文件,则会重新创建适配器,但不会发布其端口。侧车镜像为 ag-mod-search/browser:1.49.1-3;如果仍使用旧侧车镜像,或只重新创建了部分服务,浏览器路径会显示“not responding”。
格式错误的密钥会导致整个模块停止运行。 Compose 只会检查该值是否为空。若包含 A-Z a-z 0-9 _ - 以外的字符(base64 会产生 + / =),侧车容器会以状态码 78 不断退出并重启;适配器会等待侧车容器通过健康检查,因此始终无法启动,up 会提示“dependency failed to start”。
如果你以 warn 或 on 模式运行入口,还要阅读 Configuration 下的“Moving to on”:探测器现在需要令牌;没有作用域的令牌(或将 scopes 写成字符串的令牌)可能无法调用任何工具;/healthz?deep=1 则需要一个可用于搜索或读取的令牌。
⬆️ 从 0.2.x 升级——答案的结构已改变
如果你运行的是 0.2.1 或更早版本,请在更新前阅读此部分。 这里没有可以忽略的新功能; 而是说明现有调用返回的内容会有哪些不同。
搜索和图像答案默认携带的字段更少了,名称也体现了这一点:ag.search/3 和 ag.images/3,而不是 /2。如果调用方根据 contract 进行分支,运行时会明确报错,这正是我们想要的效果——在大多数语言中,一个悄然消失的字段会让人以为“一切正常”。
| 在 0.2.x 中 | 在 0.3 中 |
|---|---|
search_aborted、engines_unasked | trouble.search_aborted、trouble.engines_unasked |
unresponsive_engines | trouble.unresponsive_engines |
engines_irrelevant | trouble.engines_irrelevant |
pool_source: "seed" | trouble.pool_unmeasured,并附带原因 |
arguments_adjusted | trouble.arguments_adjusted |
corroborated_by_url、corroborated_by_domain——始终存在,在低成本路径中为 1 | 如果只有一个引擎找到结果,则不返回:此时的 1 表示“没有询问其他引擎”,而非测量结果 |
count、query、page、read、read_top、pages_*、timing_ms、engines_skipped、engines_used、tiers_used、pool_source、pool_reason | 按需返回:verbose: true |
trouble 始终存在;无异常时为空,因此可以用 if not trouble 替代四项单独检查。模块中没有删除任何内容——相关统计改为按请求返回。
布尔值现在会经过解析,而不是直接转换。 在 0.2.x 中,通过 MCP 传入 "read": "false" 仍会读取页面——耗时是正常情况下的八倍;而普通入口能正确理解同一个词。现在两个入口都接受 true/false、1/0、yes/no、on/off、y/n、t/f 以及 Python 的 True/False,且不区分大小写。无法读取的值会关闭该标志,并记录在 arguments_adjusted 中;空值也算无法读取:read="" 不再会悄无声息地触发高成本默认行为。
read_top: 0 表示“无偏好”,而不是“什么也不读”——要不读取任何内容,请使用 read: false。过去,架构声明了 minimum: 1,却仍接受零。
逐字段对照表见契约文档:
contracts/ag.search.v3.md 和
contracts/ag.images.v3.md,参见“与 /2 相比的变化”一节。
读取(ag.read/2)、深度搜索(ag.deep/2)和截图(ag.shot/1)保留了原有编号:它们新增了字段,而新增字段不会破坏任何调用方。
⚙️ 配置
| 变量 | 必填 | 说明 |
|---|---|---|
SEARXNG_SECRET | 是 | 元搜索的会话密钥。使用任意长的随机字符串,不要使用任何人的历史记录中的内容。 |
BROWSER_WS_SECRET | 是 | 浏览器 sidecar 的机密端点路径,长度为 16-256 个字符,仅包含 A-Z a-z 0-9 _ -(openssl rand -hex 16)。缺少此值时,sidecar 会在启动时退出,compose 文件也会先行拒绝启动。包含其他字符的值会通过 compose 文件检查,但会导致整个模块停止:sidecar 会以状态码 78 退出,而等待 sidecar 健康的适配器永远不会启动。要轮换此值,请修改它,然后像安装时一样使用相同的 up -d 和 -f 文件;由于 browser 和 ag-search 的环境都发生了变化,两者都会重新创建。 |
LLM_API_KEY | 否 | 任何兼容 OpenAI 的端点的密钥。机密。 |
LLM_API_BASE | 否 | 该端点的基础 URL。请从服务提供商的文档中获取,不要凭类比猜测——看似显而易见的猜测可能会返回账单错误,例如 429: Insufficient balance,因为有些服务提供商会在同一域名的不同路径上提供订阅服务。 |
LLM_MODEL_TEXT | 否 | 用于规划查询和组织答案的模型。没有提供默认值:默认值可能会在不知情的情况下向服务提供商请求其可能不支持的模型。 |
LLM_MODEL_VISION | 否 | 用于读取扫描版 PDF 的模型。未设置时,遇到此类文档会明确拒绝处理,并说明原因。 |
LLM_DISABLE_THINKING | 否 | 对于推理预算会耗尽回答、导致回答为空且 finish_reason: length 的服务提供商,请设置此项。 |
LLM_MAX_CONCURRENT | 否 | 我们主动控制而非让服务提供商拒绝的同时进行的模型调用数(默认值为 20)。服务提供商会在查询已组织、页面已读取之后拒绝超出限制的调用,此时成本最高;在开始时等待一个空位只会造成延迟。自 0.3.5 起,等待时间设有上限:如果调用在自身时限内找不到空闲位置,我们会拒绝该调用(no free model slot),不会调用模型;此时扫描会返回 not_recognised,深度搜索会回答 busy。请将此值设为低于服务提供商声明的上限:服务提供商按账户计算调用量,而一个密钥通常供多个实例共用。 |
LLM_RATE_LIMIT_WAIT_S | 否 | 当服务提供商返回 429,且没有可用的 Retry-After 时等待的时间——包括缺失、为零、为负数或是日期的情况(默认值为 5,且永不低于 1)。重试等待时间为零秒,就等于再次发送相同请求。 |
LLM_RATE_LIMIT_CODES | 否 | 除 HTTP 429 外,服务提供商自有的、表示“请求过多”的错误码,以逗号分隔。会与错误响应正文中的 error.code(或顶层 code)进行精确匹配;绝不会搜索正文中的文字。留空时使用默认值:只有 429 会被计入。任何 429 都会被计入——如果服务提供商将账单拒绝作为 429 发送,也会报告为 busy,并在 error 中包含服务提供商返回的文字。 |
DEEP_RATE_LIMIT_MAX_WAIT_S | 否 | 深度搜索遇到速率限制后为唯一一次重试等待的最长时间(默认值为 10);如果等待时间更长,则会返回 cause: busy。 |
AG_DOOR_AUTH | 否 | 谁可以调用:off(默认值,不检查——模块一直以来的行为)、warn(提供工具服务,但统计没有携带可用令牌的调用次数;但 /healthz?deep=1 也会被 warn 拒绝,详见下文)、on(拒绝此类调用:没有令牌时返回 401,令牌无权调用该工具时返回 403)。大小写和首尾空格不影响结果;未设置或为空时为 off。任何其他值都会导致服务器无法启动,错误消息会显示该值和三个允许的值:0.3.4 之前,像 enforce 或 0n 这样的拼写错误会被静默视为 off。请按 off → warn → on 的顺序切换:如果直接以 on 启动,尚未发放令牌的调用方会被拒之门外。 |
AG_DOOR_TOKENS_IN_CONTAINER | 否 | 容器内部令牌列表的路径。compose 文件会将 ./door-tokens.json 挂载到 /run/door-tokens.json,因此请设置为 /run/door-tokens.json;未设置时不会读取令牌列表。文件格式为 {"<token>": {"name": "...", "scopes": ["read"]}}。**机密,绝不能提交到 git。**范围可以是工具名称、read 和 paid 组,或 *。范围列表缺失或为空时不会授予任何权限,scopes 为字符串而非列表时也一样——只有列表中明确包含 "*" 才会授予全部权限——并且 /stats 会在 tokens_granting_nothing 下按名称列出此类令牌(字符串类型的还会列在 tokens_scopes_not_a_list 下)。paid 对应 web_deep_search,持有此范围也可以让 web_read 在读取扫描版 PDF 时使用视觉模型:在 on 模式下,没有 paid 的令牌仍可读取,但扫描件会返回 not_recognised (scan),并指出缺少的范围。门禁会读取名称和范围,但绝不会回显令牌——拒绝时不会回显,计数器中也不会回显。普通的 /healthz 在所有模式下都保持开放;/healthz?deep=1 会执行真实搜索和读取,在 warn 和 on 模式下需要持有可调用 web_search 或 web_read 的令牌(没有令牌时返回 401,持有无权调用两者的令牌时返回 403)。 |
PROBE_DOOR_TOKEN | 否 | 探测器用于门禁的令牌:从令牌列表中选择一个仅拥有 web_read 范围的令牌。切换到 warn 前必须设置——否则每次读取探测都会计为 unauthenticated,该计数器会一直增长;在 on 模式下,门禁会拒绝探测请求,探测器不会记录这些请求,并会在日志中说明原因。为空时不会发送令牌。机密:仅放在 .env 中。 |
AG_DOOR_TLS_CERT、AG_DOOR_TLS_KEY、AG_DOOR_TLS_PORT | 否 | 在独立端口上启用 TLS,与普通端口并行。证书和密钥的路径必须是容器内部路径——compose 文件会将 ./tls 挂载到 /run/tls——并设置监听端口。必须全部设置或全部不设置:只设置其中任意两个,或证书无法加载,都会导致门禁无法启动。主端口始终使用普通连接,因此 compose 健康检查仍可正常运行。wiring/expose-localhost.yml 不会公开 TLS 端口。 |
READ_CONTACT | 否 | 抓取页面时放入 User-Agent 的联系信息。默认为此代码库;如果要大规模运行,请设置自己的联系信息。 |
READ_LANGUAGES | 否 | 读取时使用的 Accept-Language。默认不设置——要读取哪些语言的页面,并非由我们决定。 |
无法读取的令牌文件,不等于空令牌文件。 在真实部署中遇到过这种情况:文件以 600 权限挂载到容器中,所有者是 root,而容器以非特权用户身份运行。门禁既未放行,也未拒绝,看起来配置正确——无法查看文件这一事实,被当成了查看后的结果返回。因此,/stats 包含三个独立字段:door_auth(模式)、tokens_configured(是否指定了文件名)和 tokens_readable(是否至少读取到一个令牌——可读取的 {} 也会返回 false)。在 on 模式下,无法读取的列表会拒绝所有人——对于无法读取的文件,这才是安全的做法;在 warn 模式下,它会为所有人提供服务,这正是两种情况不能共用一个字段的原因。路径不存在则更糟:Docker 会将其创建为目录,读取时看起来就像无法读取的文件——因此请在首次 up 之前创建文件(上面的安装代码块已完成此操作),并确认它是一个文件。 |
谁拥有机密文件。 适配器以 uid 10001 运行。令牌文件中存有令牌后,以及对于 TLS 密钥,请执行:chown 10001:10001 door-tokens.json tls/key.pem && chmod 600 door-tokens.json tls/key.pem。上面故障看似诱人的修复方法是 chmod 644,但这会让主机上的所有用户都能读取令牌和私钥。
切换到 on。 先给探测器配置令牌(PROBE_DOOR_TOKEN),然后依次切换 off → warn;当 /stats 中的 unauthenticated 不再增长时,再切换到 on——探测器使用令牌后,该字段中剩下的就是尚未配置令牌的调用方。从 0.3.5 起,日志会显示调用方身份:每个请求都会打印一行,[door] <address> <method> <path> -> <status> in <ms> ms, caller: <token name>(没有令牌时显示 no token;令牌列表中没有该令牌时显示 unknown token——调用方收到的拒绝响应无法区分这两种情况,但日志可以;未作出门禁判定时显示 -:请求在读取前就被拒绝、门禁不处理的方法(如 HEAD 或 OPTIONS),或发送到 /mcp 以外路径的 POST),如果请求带有查询字符串则会显示 ?…;如果客户端发送了自己的 X-Forwarded-For,也会显示该值,但未经验证。日志绝不会记录令牌、其他请求头、正文或查询字符串。
浏览器的会话和看门狗限制在 .env 中设置;请参阅 .env.example,其中会在每个设置项旁说明其用途。节奏控制、池大小和读取限制在 adapter/server.py、adapter/reader.py 和 adapter/pool.py 中设有经过测量的默认值;docker-compose.yml 不会传递这些设置。
模型密钥为可选项。 搜索、读取、图片搜索和截图都是 HTTP 请求,不会消耗模型令牌。只有在两个地方会调用模型,答案中也会明确标出这两处:web_deep_search,以及识别没有文本层的 PDF——只有在你要求读取这类文档时才会发生,web_search 绝不会在你不知情的情况下执行识别。web_deep_search 会在启用识别的情况下读取所选页面,因此其中的扫描件会作为该搜索自身模型调用开销的一部分进行识别。启用门禁 on 后,这两种情况都需要带有 paid 范围的令牌。
🎯 捆绑式搜索工具做不到的事
成本由你掌控。 一个参数就能让答案的成本相差一个数量级:
| 调用 | 负载 | 耗时 | 模型令牌 |
|---|---|---|---|
read: false,6 个链接 | 3.8 KB | 0.6 s | 0 |
read_top: 1,3 个链接 | 8.8 KB | 1.6 s | 0 |
read_top: 3,6 个链接 | 9.8 KB | 6.3 s | 0 |
web_deep_search | 完整账户 | 36 s | 6 次调用 |
在一台机器上对一个查询进行测量。参考趋势,不要纠结数字。 一位使用者从外部测量了同一个分支,结果为 4.8–10.5 s,而非 0.7 s——趋势不变,具体数字取决于查询恰好找到的页面。
在调用前做出选择,而不是在账单出来后。 当你在梳理现有内容或需要严格控制上下文时,使用 read: false;想获取排名靠前结果的文本,且本来需要自己去抓取时,则使用默认值。read_top: 0 的意思是“无偏好”,而非“不读取”。
引擎列表会自行维护。 手写的列表会悄无声息地过时:曾经最好的引擎几周后不再返回任何结果,却对此只字不提。我们在一天内修订了三次——每次都基于上一次的版本,每次在各自的数据上都正确。问题从来不在引擎,而在于决策被冻结,观察却仍在继续。
因此,这里不会手动编写列表。探测器会持续向每个已知引擎提问,问题的正确答案事先已知;引擎池则根据参考命中率选出表现最好的几个——并自行重新计算。通过证伪验证:人为植入的一次糟糕运行让某个引擎无需更改代码就被移出引擎池,而恢复该次运行后,它又自行回到了池中。
在积累足够的观察结果之前,引擎池只是一个种子列表,每个答案都会明确说明这一点:默认显示 trouble.pool_unmeasured,而在 verbose 模式下显示 pool_source 本身。
失败与成功可以区分。 引擎可能以四种方式失败,各自都有对应的提示:
| 失败方式 | 提示内容 |
|---|---|
| 返回拒绝结果:验证码、速率限制、封禁 | trouble.unresponsive_engines |
| 悄无声息地不返回任何结果 | engines_asked 与 engines_answered 之间的差异 |
| 回答了另一个问题 | trouble.engines_irrelevant——其结果已被丢弃 |
| 将主题替换成索引排名更靠前的同名对象 | engines_trust,根据参考数据得出 |
读取页面时也一样:有七种不同的结果状态,返回屏蔽页的页面状态是 stub,而不是空文本。
🩺 模块停止运行,但看起来一切正常时
本系统有一种故障不会出现在你通常会监控的任何指标中,因此我们把它记在这里,免得以后又得重新发现。
症状。 容器报告不健康,docker compose up 拒绝启动适配器,而 docker exec 报错 runc: nsexec ... setns。负载、内存和磁盘都正常。
只需一条命令即可检查:
ps -eo comm | sort | uniq -c | sort -rn | head
如果 headless_shell 的数量达到几千,说明浏览器 sidecar 累积了大量进程——已启动却未关闭的浏览器会让其渲染器一直存活;数量超过几千后,容器运行时就再也无法启动任何进程。由于两个健康检查都通过 exec 执行,一切都会同时报告故障,而真正的原因看起来就像是你最近更改的内容。
解决方法: docker compose up -d --force-recreate browser。在我们自己的实例上测得:进程数从 4619 降到了 126。
0.3.1 的改动。 这些进程其实从未真正存活:访问结束时,chromium 的子进程会成为孤儿;而不调用 wait() 的 PID 1 会让它们一个个变成僵尸进程,占据进程槽位——实测访问十次后就有四十个。现在 sidecar 镜像会运行真正的 init 来回收这些进程(如果有人替换镜像,compose 文件中的 init: true 也会起到相同作用),并且模块会在每种路径下关闭所有浏览器,包括失败的路径。
第二种无声故障:每次浏览器读取都会卡住,随后磁盘被占满
症状。 使用浏览器的 web_read 和 /healthz 本身停止响应,但搜索仍然可用;如果多个适配器共用一个 sidecar,它们的所有浏览器读取都会失败;随后机器磁盘被占满,却没有日志增长。
检查——数线程,而不是进程:
docker stats --no-stream --format '{{.Name}} {{.PIDs}}' | grep browser
pids_limit 统计容器的每个线程。空闲的 Chromium 进程树约有 42 个线程,run-server 自身的基础线程数约为 14;达到上限后,每个新浏览器都会因无法创建线程而失败并崩溃,但 sidecar 自身的健康检查——TCP 连接——仍显示正常。ps 显示只有少数几个进程,看不出任何问题。
0.3.2 做出的更改,是在 2026-09-23 确切发生以下情况后推出的——一波并发读取占满了上限,11 个会话在没有超时的阶段卡了 6 个小时,健康探测在锁后面卡住,每个崩溃的 Chromium 都向容器层写入核心转储,直到磁盘被占满:
- 每个浏览器会话都在独立子进程中运行;如果运行时间超过预算加上
BROWSER_KILL_GRACE_S(默认 10 s),就会连同整个进程组一起终止;调用方会收到说明原因的not_reached,并指出责任在 sidecar 还是页面——如果页面脚本一直不让出执行权,问题就在页面,不会因此将 sidecar 标记为异常; - 每个适配器实例同时最多持有
BROWSER_MAX_SESSIONS个会话(默认 2 个,包括健康探测);超过上限的读取最多等待BROWSER_SLOT_WAIT_S(默认 5 s),之后会收到浏览器繁忙的提示,而不是启动 sidecar 无法运行的浏览器;等待时间会从会话时长中扣除,会话预算会缩减为调用剩余的时间——只有仍然卡住的会话才会在此后BROWSER_KILL_GRACE_S(10 s)时被终止,而在最后一刻才响应的会话最多还能多花 3 s 退出,因此使用默认值时,调用可能超出时限上限最多 13 s;如果某个会话可能在 sidecar 上留下浏览器(启动期间被终止,或关闭过程卡住),对应的插槽会被暂时停用BROWSER_COOLDOWN_S(默认 150 s),由 sidecar 的看门狗清理; /healthz永远不会启动浏览器,也不会等待浏览器:浏览器状态取自上次真实会话的结果及其时间,并结合端口检查;真实探测在后台最多每隔BROWSER_PROBE_S(默认 600 s)运行一次——如果上次判定为“无响应”,则会在每次健康检查时运行一次,因此恢复情况会在几分钟内显示,而不是等十分钟;启动后的首次探测完成前,状态显示为not checked yet: …,并计为降级;- sidecar 的主命令是一个看门狗(
browser/supervise.py),无论客户端做了什么,都会终止任何运行时间超过BROWSER_MAX_AGE_S(默认 120 s——最长的正常会话为 42.5 s)的 Chromium 浏览器; - 适配器、探测器和浏览器都设置了
ulimits: core: 0,因此都不会写入核心转储;适配器镜像还自带 init。
如果问题仍然发生,解决方法: docker compose up -d --force-recreate browser。适配器无需做任何处理:从 0.3.2 起,它们的 /healthz 会持续响应;会话插槽一释放,浏览器状态就会在一次健康检查间隔内自行恢复为 alive。如果 sidecar 卡住期间会话被终止,对应插槽会被暂时停用 BROWSER_COOLDOWN_S(150 s),因此完全恢复最多需要约三分钟——在一个测试实例上测得,从重新创建 sidecar 到状态恢复为 alive 用时 167 s,中间没有执行任何读取。如果有插槽可用,GET /pages 会立即运行一次真实浏览器会话;如果更重要的是跳过等待,可以运行 docker compose restart ag-search。例外是低于 0.3.2 的适配器:它的 /healthz 可能会卡住,因此需要运行 docker compose up -d --force-recreate ag-search,并升级。
⚖️ 它如何处理 robots.txt,以及为什么必须由你决定
此模块会报告网站规则,但不会强制执行。 每次读取都会带有 robots 字段:allowed、disallowed_by_site,或者无法读取文件时的 not_checked。网站禁止访问的页面仍会被抓取,答案也会说明这一点。
allowed 表示我们读取了规则,且规则允许访问此路径,而不仅仅是没有看到禁止条款。网站表示文件不存在(404)时,就没有规则,此时为 allowed。如果文件根本没有返回——例如 5xx、超时、连接中断、401/403——或者返回的编码无法解析,则为 not_checked。这些情况并不相同,该字段会将它们区分开:没有数据就表示“未检查”,绝不表示“已允许”。
这是一个决定,而非疏漏;该由运行此模块的人决定,而不是由模块决定。原因有两个。由个人调用的工具是否遵守 robots.txt,应由操作者决定——为索引网络内容的爬虫编写的规则,显然未必适用于抓取用户要求读取的单个页面。参考行为会将 robots.txt 上的 401/403 视为禁止,但这会导致普通来源被误判为禁止,因为同一网站在反爬服务后方会向所有人返回 401。我们不会将此判定为禁止,但也不会将其判定为允许:答案是 not_checked,它如实说明了实际情况——规则存在或不存在,但我们没能看到它们。
因此,是否设置拦截由你决定。 如果你的用途要求遵守 robots 规则,请根据该字段进行分支处理:robots == "disallowed_by_site" 表示网站不允许。如果你选择遵守,也应将 not_checked 视为停止信号——它表示我们无法读取规则,而不是没有规则。
📖 工作原理
- ALGORITHM.md — 每次调用时发生的事情,逐步说明。
- contracts/ — 调用契约,与实现这些契约的代码分别进行版本管理。
- measures/ — 带日期的测量结果:哪些引擎处于运行状态、负载阶梯的结果,以及传输方式变更带来的效果。包含数字,以及测量内容和时间。
- contracts/ag.search.v3.md — 以及相邻文档:各项能力的承诺,以及针对
/1和/2的变更对照表,供需要查阅已存储答案的人参考。
🔒 部署与暴露
wiring/expose-localhost.yml 仅在 127.0.0.1 上发布适配器,这是唯一随附的覆盖配置。任何范围更广的配置都需要你自行编写,而 wiring/expose-localhost.yml 的开头说明了首先要检查什么:Docker 会在 DNAT 后通过 FORWARD 转发已发布端口的流量,而防火墙自己的链位于其钩子之前——因此,防火墙即使显示“已关闭”,已发布端口也可能对互联网开放。
对外开放的搜索服务就是一个以机器所有者名义访问网络的开放代理。
此版本已知的限制,明确列出,以免被误认为不存在:
- 浏览器路径通过浏览器内的脚本和路由,而非网络,将页面隔离在边界之外。 有三种情况仍未解决,彼此各不相同。DNS 重绑定尚未阻止,页面也不会被拦截:内外部判定会在会话期间保留,浏览器会连接其自身 DNS 查询返回的地址。页面会检测到跳转至内部的重定向,以及页面脚本无法访问的推测规则预取(位于封闭的声明式 Shadow DOM 根中,或解析器在宿主上暂停后创建的开放根中),并拦截页面,但这两种情况都要等请求发出后才会处理。页面打开的弹出窗口发起的预取不会受到监控,该页面也不会被拦截。自 0.3.5 起,页面脚本能够访问的规则会被移除。完整列表见 contracts/ag.read.v2.md;要彻底解决此问题,需要代理或 sidecar 上的出口规则。
- 门禁日志可能会记录错误的 forwarded-for。 在 keep-alive 连接上,如果请求在解析请求头之前被拒绝(400、414、431、505),日志会记录前一个请求的
X-Forwarded-For。无论是哪种情况,该字段都会标记为未验证;此问题在 0.3.5 中仍未解决,并将在下一版本修复。 - 深度搜索会把自身队列的拒绝说成是提供方的拒绝。 如果没有模型槽位及时空闲,答案的
cause会按文档所述设为busy,但其error文本会说模型受到速率限制,而不是指出LLM_MAX_CONCURRENT。只有文本有误。 - sidecar 内的 root 用户可以看到
BROWSER_WS_SECRET。 tini 和 sidecar 的 supervisor 会将该变量保留在其启动时的环境中,而容器内的 root 可以通过/proc读取它。sidecar 以 root 身份运行,浏览器则使用--no-sandbox运行,因此如果页面接管了渲染器,就可能读取该变量;但此时它已经是此密钥所保护的 sidecar 内的 root 用户。 web_read的调用时限无法限制其下方的所有操作。 浏览器会话可能挂起,某个扫描页面在停止前刚开始的渲染也可能超时结束。自 0.3.5 起,等待空闲模型槽位的时间和模型生成完整答案的时间都受此时限约束。请参阅 HOWTO-CALL.md 中的限制表。
✅ 测试
IMAGE=ag-mod-search/adapter:0.3.5 bash tests/in-image.sh
四个测试套件——针对模拟元搜索服务的协议和搜索测试、针对模拟网站的读取测试、针对内存中构建的数据库的计算池测试,以及针对模型提供方预设回复的答案解析测试。其中没有任何一个会发出外部请求;运行器通过 --network none 强制执行这一点,而非依赖信任:对于读取测试,这比搜索测试更重要,因为访问互联网的测试会消耗此工具所保护的资源——它发起请求时所用的那个地址的信誉。
这些测试会在构建好的镜像内运行,而不是在编辑代码的机器上运行:PDF 解析器位于镜像中,在镜像外运行测试套件会跳过所有涉及它的测试。跳过不会悄无声息——检查会报红,并注明应在哪里运行。
这些测试套件无法检查的内容已记录在 tests/README.md 中。
🤝 贡献
在声明参考属性——即正确答案的一项属性,且被测试的能力、引擎或启发式方法无法自行告知我们的属性——并附上一组经过审核的问题之前,我们不会接受该能力、引擎或启发式方法。请参阅 贡献指南。
📄 许可证
GNU Affero 通用公共许可证 v3.0。运行、修改,并基于它进行构建。如果你通过网络向他人提供它,就必须将你所做的更改也以相同的许可证发布——这是唯一的义务,而运行服务也算作向他人提供。
对于无法接受这一条款的人来说,应该考虑申请商业许可证,而不是创建分支。
评论
一个开放协议,规定了 AI 应用如何以统一方式连接外部工具与数据源。客户端负责与模型对话,MCP 服务负责提供工具、资源和提示模板,两者通过标准消息通信,同一个服务可以被不同客户端复用。