把五个网页版 AI 拉进同一个群聊:不用 API key 的多 Agent 编排

0 阅读1分钟

上周做一次技术选型,同一个问题我问了五个模型,拿到五份答案。最后我采纳的,是我一开始就想选的那个。

这件事比"模型答错了"更麻烦:单模型真正的问题不是它会错,而是你没有办法发现它错。 你只有一个视角的时候,那个视角就是全部现实。你只能靠自己的判断力去校验它,而你的判断力恰好是这整件事里最弱的一环。

于是我做了个东西:把 ChatGPT、Claude、Gemini、DeepSeek、Grok 的网页版拉进同一个群聊,@ 谁谁发言,多个 AI 能看到彼此说过的话,可以互相反驳。

它叫 OpenTeam,一个 Chrome 扩展。这篇文章不讲"我做了个",讲三个工程问题:为什么不用 API、群聊是怎么跑起来的、以及最难的那个——你怎么知道一个网页 AI 已经回答完了。


一、为什么不用 API

先说我放弃了什么。

用 API 做多模型编排是标准做法,成熟、稳定、可控。我不用它,只有一个理由:成本结构不对。

多模型讨论的 token 消耗不是线性的。五个角色讨论三轮,每一轮每个角色都要读到其他所有人的发言,消息数按平方涨。一份两万字的技术方案,五个模型各读一遍是 10 万字符,讨论三轮就是 30 万字符起步。这笔钱按 API 价格算,一次评审任务几块钱到几十块,取决于上下文长度。

但如果用网页版:我本来就有这五个平台的账号,网页额度是免费的、是按订阅给的、是我已经在付的钱。再买一次 API,等于为同一份算力付第二次费。

第二个理由是门槛。五个平台 = 五个 key = 五个账单 = 五套限流规则 = 五个 SDK。任何一个过期,整条链路就断。而网页版没有这个问题:你登录着,它就能用。

代价必须说清楚,因为这是这个方案全部的脆弱性所在:

  • 它靠 DOM 自动化。网站改一次版,选择器就失效,扩展就哑了。这不是 bug,这是这个架构的本质。
  • 它受站点风控约束。高频操作会被识别成机器人,账号有被限制的风险。
  • 它无法并发大规模调用。你要开五个真实的浏览器 frame,每个 frame 都在渲染一个完整的 SPA。五个就是五个的重量。
  • 它拿不到 token 级的信息。没有 finish_reason,没有 usage,只有一个正在变化的 DOM。

API 给你一条干净的管道;网页会话给你一个正在被人操作的界面。你只能去猜它的状态。而猜状态,就是这个项目 80% 的工程量。


二、一个群聊的架构

team.html            扩展内的群聊页面(消息流、成员列表、@ 输入)
  ↓ chrome.runtime
background.js        service worker:路由、队列、消息总线、上下文裁剪
  ↓ 按站点分发
iframe × N           每个 AI 站点一个 iframe(会话隔离)
  ↓
content.js           注入到每个站点的 frame 里(all_frames: true)
                     负责:填输入框、点发送、观察流式输出、抓取回复
  ↓
回复文本 / 图片       回传到 service worker,再汇总进群聊消息流

三个设计决定值得展开。

1. 为什么用 iframe,不是新开 tab。

新开 tab 是最简单的做法,但它有三个问题:会话不隔离(同一个站点的多个角色会互相踩)、切 tab 会触发浏览器对后台页面的节流、而且你没法"定点观察"某个角色——你得靠 chrome.tabs 反复查。

iframe 是并行的、隔离的、可定点观察的。代价是站点默认禁止被 iframe 嵌入,所以必须动响应头——这就是 declarativeNetRequest 存在的唯一原因。这也是审核最容易被人工看一眼的地方:一个扩展要求改写 X-Frame-Options 和 Content-Security-Policy,审核员一定会问为什么。

2. 为什么 content script 必须住在每个站点的 frame 里。

跨域。你在扩展页面里拿不到 chatgpt.com 的 DOM,一行都拿不到。所有对输入框的写入、对输出区域的观察,都必须在那个站点的 frame 上下文里执行。所以 content script 注册时是 all_frames: true,跟着每一个目标站点的 frame 一起注入。

3. 为什么每个站点一个 adapter,而不是一套通用逻辑。

五个站点的 DOM 结构毫无共性。输入框一个是 contenteditable,一个是 textarea,一个外面套了两层 shadow DOM。发送按钮一个是 <button>,一个是靠回车。流式输出的容器,有的每 100ms 追加一个 <p>,有的整块重绘。

所以代码里有一个 ChatSiteAdapter 接口,每个站点一份实现,各自回答四个问题:输入框在哪、怎么发送、回复容器怎么认、以及——这条回复写完了没有。


三、最难的问题:怎么判断"回答完了"

这是整个项目里我改得最多的一段代码,也是最容易做错的地方。

如果你有 API,答案是一行:finish_reason === 'stop'。你没有。你只有一个 DOM,它在不停地变。你能观察到的只有一件事:文本长度什么时候不再增长。

听起来简单,直接轮询文本长度不就行了。实际上有四个坑,每个都能让整个群聊崩掉。

坑 1:停顿 ≠ 结束

模型输出到一半会停。它在思考、在等网络、在渲染 Markdown 代码块、或者被服务端限流卡了两秒。如果你把"长度两秒没变"当成结束,你会拿到半截答案,而且会一直拿半截答案——因为下一轮讨论里,其他模型读到的是这半截。

我的做法是给不同的"静止时长"分档:

const RESPONSE_DEBOUNCE_MS = 2500          // 观察防抖
const RESPONSE_FINAL_SETTLE_MS = 1500      // 最终稳定阈值
const REPLY_POLL_INTERVAL_MS = 2000        // 补偿轮询间隔
const REPLY_TIMEOUT_MS = 120000            // 单角色超时上限
const SHORT_REPLY_MAX_CHARS = 50
const SHORT_REPLY_STABLE_SETTLE_MS = 5000  // 短回复要静更久

补充一句:单角色超时 120 秒这个值不是拍的,是 issue #58(DeepSeek 页面偶尔触发"等待回复超时")之后按最长的实测响应时间往上留的余量。

回到分档,最后一行是踩出来的:短回复需要更长的稳定时间。"好的"、"是的"、"不需要"——这类回复只有几个字,长度在两次轮询之间根本不变,所以"静止"这个信号对它们完全没有区分度。你必须在短文本上把阈值从 1.5 秒拉到 5 秒,否则每个短回答都会提前被判完成,然后你会看到群聊里五个角色几乎同时开始说"好的"。

坑 2:回复容器会重绘

有的站点不是追加节点,是整块替换。你上一秒抓到的 element 引用,下一秒就 detached 了。它的 textContent 还留在内存里,但永远不会再更新。

所以不能只认元素,必须认"这一轮回复的身份":容器在 DOM 树里的位置、它的层级、它的祖先链。代码里对应 responseContainers.ts —— 数出所有候选容器,只保留最深的那几个(keepDeepestResponseContainers),因为站点的嵌套结构里,同一段回复常常出现在三层容器里。

这个坑在 issue 里留下了两条记录:#57 是"轮次要数错,抓到了上一轮的历史回复",#60 是"容器被整块替换之后,超时补偿抓了个空"。两条都是容器身份识别没做好,而不是模型没回答。

坑 3:DOM 文本 ≠ 回复文本

直接读 textContent 会得到什么?会得到代码块的复制按钮文字"复制代码"、会得到"重新生成"、会得到 Markdown 渲染后丢掉的表格结构、会丢掉公式。

所以取文本有优先级:先试站点自己的"复制"按钮(拿到的就是干净的 Markdown),失败再试站点特有的 Markdown 提取,最后才回退到 textContent。而且对"复制"结果要做可信度检查:

const LONG_REPLY_TEXT_LENGTH = 160
const MIN_COPY_TO_DOM_TEXT_RATIO = 0.2

如果 DOM 里有 3000 字,剪贴板里只有 40 字,那说明复制动作抓错了东西——大概率抓到了工具栏上的"复制代码"或"重新生成"。这时候宁可用 DOM 文本,也不用剪贴板。这个 isSuspiciousCopiedReply 判断就是为了挡住这一类误抓。

坑 4:超时之后怎么办

两分钟没结束,怎么办?直接报错是最差的——用户看到的是"Claude 没有回复",而实际上它可能刚写完最后一个字。

所以有两条补偿路径:超时补偿和轮询补偿。超时之后不是立刻放弃,而是再抓一次当前容器里的内容,如果长度已经够长(说明它其实答完了,只是没触发稳定判定),就当成正常回复收下,只在日志里记一条 reply-timeout-compensated。只有当抓到的内容确实是空的,才算失败。

顺带一提,v1.0.3 里修的那个 DeepSeek editor did not accept the prompt text(issue #63)属于另一类:连"发出去"这一步都没成功,但我们把它误判成了"发出去了但在等回复"。所以现在写入输入框之后要先校验内容真的进去了,再点发送。

这三档(观察 → 稳定判定 → 超时补偿)加起来,构成了一个没有 finish_reason 的世界里,唯一能用的"完成"定义。


四、串话:多 Agent 里最隐蔽的 bug

群聊形态有一个 API 形态不存在的问题:上下文污染。

五个角色共享一条消息流。如果每个角色都无脑读全量历史,会出现三种坏情况:

  1. 角色 A 说了个错误结论,B 读到之后在 A 的框架里继续推理,C 再读 B 的——错误被放大三轮,最后全群达成一个一致但错误的共识。这叫"回音室",比单个模型答错危险得多。
  2. 上下文爆炸。五个角色的历史全塞进去,两轮就顶到上限。
  3. 角色人格被稀释。原本设定成"唱反调的评审",读了四个人的赞同之后,也开始赞同。

我的处理是两层。

第一层:独立的上下文预算。 每个站点按它的真实上下文窗口给一个字符预算,不是统一值:

站点字符预算
Gemini1,000,000
DeepSeek1,000,000
Claude500,000
ChatGPT272,000
Grok256,000

裁剪时按角色算,不按群算。同一个群里,Claude 那个角色能带 50 万字符的历史,Grok 只能带 25 万——它们看到的世界本来就不一样大。

第二层:先隔离,再碰撞。 角色有两种模式:

  • 独立模式:只看到原始问题,看不到别人的回答。用来收集"干净的第一反应"。
  • 协作模式:看到全群发言,可以引用、可以反驳。

多模型评审的正确顺序是:先让五个角色在独立模式下各答一遍(保证视角不互相污染),再切到协作模式让它们互相挑战(这时候分歧才有价值)。反过来做——一上来就协作——你得到的不是五个意见,是一个意见的五个回声。


五、这套东西适合什么,不适合什么

适合:

  • 技术方案评审。五个模型对同一个架构方案,最容易暴露的不是"哪个更好",而是"只有某一个模型发现的那个风险"。这个信息在单模型下你永远拿不到。
  • 需要多视角判断的决策。要不要重构、要不要接这个需求、这段文案有没有歧义。
  • 长文档的多轮处理。一个角色摘要、一个角色找漏洞、一个角色改写,串起来跑。

不适合,而且用了会更糟:

  • 要精确答案的时候。五个模型给你五种口径,你还得自己裁决。这不会让你更确定,只会让你更犹豫。
  • 需要长链路执行的任务。它不是 agent 执行器,没有工具调用,不能跑代码、不能改文件。它只会说话。
  • 高频、批量、要稳定性的场景。DOM 自动化本身就意味着它随时可能因为对方改版而失效。

还有三条必须写在明处的限制:只支持 Chromium 系浏览器;依赖你已经登录的网页会话,账号被风控时它会一起挂;许可证是 PolyForm Noncommercial 1.0.0,仅限个人非商业使用。


六、装起来只要三步

不用 clone、不用 npm、不用编译。仓库的 Release 里有打好包的 zip:

  1. 从 Releases 下载最新的 openteam-extension-v1.0.3.zip,解压到一个你不会删掉的目录。
  2. 打开 chrome://extensions,右上角开启「开发者模式」。
  3. 点「加载已解压的扩展程序」,选刚解压出来的目录。

然后在浏览器里正常登录 ChatGPT / Claude / Gemini 等站点,点扩展图标打开群聊页,把角色加进群,@ 它说话。内置 38 个顾问模板可以先用,也可以自己加人。

源码在 github.com/afumu/opent…,TypeScript + Manifest V3,约 4.8 万行。


最后

这套东西没有解决"AI 会犯错"的问题,它解决的是另一个问题:当你只有一个答案时,你没有能力判断它错在哪里。

五个模型吵起来的时候,分歧本身就是信息。你会看到某个模型注意到了别人都没提的边界条件,也会看到四个模型一起犯同一个错——后者往往比前者更有价值,因为那说明这个错藏在训练数据里,而不是藏在某次采样里。

所以想问你一个问题,也是我一直在调的参数:如果你的五个 AI 意见不一致,你希望它们投票、互相反驳,还是各自单独输出?