Web Agent 自己跟自己较劲:GUI 反馈闭环那次事故
有一类 bug 你在自己写代码的时候几乎不会遇到,但只要让 agent 去改一个「它自己正跑在里面」的界面,它就会准时出现:改完了,构建也过了,服务也起来了,返回码是 200,然后它告诉你「已完成,请刷新查看」——你刷新,页面纹丝不动,或者干脆一片空白。
DeepSeek Harness 仓库把这件事写成了一份事故复盘,文件在 docs/postmortem/0003-web-agent-gui-feedback-loop.md(同目录有 .zh.md 中文版)。标题直译过来是「Web agent 验收了替代服务器,而非它当前的 GUI」。这篇我不复述那份文档,我沿着它的修复动作把代码路径走一遍,顺手把两处文档与代码对不上的地方标出来。
先说清楚限定:这个仓库的 README 自述处于开发者预览阶段,并明写未来会有破坏兼容性的变更(README.md 第 11 行,中文版第 11 行)。下面提到的命令、配置项、默认值随时会变,我们也没有安装、没有运行过它,全部内容都是读仓库快照得来的。
事故本身:五个「事实」被当成了同一个
复盘文档的时间线部分记录得很细,依据是那次会话持久化的事件日志,每一步都给了序列号。压缩成一句话:agent 改了 GUI 的主题源码,然后
- 第 2 轮,它把验收交还给用户,让用户自己去开「某个 Web 应用」,自己没做任何组装后的验收(序列 30939);
- 第 3 轮,它读了
apps/web/package.json,在 5173 端口起了裸 Vite,看到 HTTP 200 就宣布成功(序列 31865)。浏览器那边抛的是client-modules: window.__DSH_BOOT__ is missing or not an object,白屏; - 第 4 轮,它找到了完整的
dsh web启动路径,重新构建 shell,在 3334 端口起了一个不受管理的进程(序列 34309),只验证了这个替代服务返回 200 且带启动 manifest(序列 34441)。它从未探测过用户实际在用的那个端口; - 第 5 轮,用户报告说自己那个页面早就显示新主题了(序列 34556),agent 这才回去看既有进程、把多余的服务器清掉(序列 34681)。
复盘文档的「概述」段里有一句我觉得值得单独拎出来(不在根因段,别找错地方):源码修改、构建成功、HTTP 200、注入的启动 manifest、用户原本打开的页面,这五样被当成了可以互相替代的事实。它们不是。
第一处:HTTP 200 和「应用就绪」不是一回事
那句白屏报错不是虚构的错误文案,它在 packages/client/modules/src/client/manifest.ts 里,解析函数拿到的 window.__DSH_BOOT__ 不是对象时直接抛出,字符串和复盘文档里引用的完全一致。
而写入这个全局变量的是宿主侧:同一个包的 packages/client/modules/src/index.ts,把组装好的入口图序列化后拼成一段 <script>window.__DSH_BOOT__ = …</script> 注入 index.html。也就是说,谁提供 HTML,谁才有机会注入这份清单。裸 Vite 提供得了 HTML,提供不了清单——传输层就绪,应用层没就绪。这就是 200 骗人的全部机制。
第二处:现在裸 Vite 根本起不来
修复的做法很直接:不让你走错路,而不是走错了再报错。apps/web/vite.config.ts 顶部定义了一个叫 rejectStandaloneServe 的 Vite 插件,插件名是 dsh-reject-standalone-web-serve,逻辑只有一行——在 config 钩子里判断 env.command === 'serve' 就 throw。它被放在 plugins 数组的第一位,排在 react() 前面。
抛出的文案(文件里叫 STANDALONE_ERROR)本身就是一份指路牌,三句话:apps/web 不是独立应用,裸 Vite 无法注入 window.__DSH_BOOT__;从仓库检出运行请用 pnpm dsh web,装好的包用 dsh web;要做客户端插件的 HMR,得 pnpm dsh web 和 pnpm run dev:web 一起跑。
注意 config 钩子的位置——它在服务器监听之前。这不是随口一说,回归测试专门盯着这件事。
第三处:回归测试得能为「所报告的机制」失败
复盘文档的根因段承认,第一版回归测试重复了同样的错误:超时机制杀掉 Vite,非零退出的断言照样通过,是个误报。
现在的测试在 apps/web/tests/vite-entry.e2e.ts,两个用例。第二个用例的做法值得抄:它先在临时目录里准备一个叫 listen-called 的 marker 路径,通过 DSH_LISTEN_PROBE_MARKER 环境变量和 NODE_OPTIONS 的 --import 把 tests/support/listen-probe.mjs 挂进子进程,去插桩 Server.listen();随后断言 result.timedOut 为 false、退出码非零、stderr 里同时出现 apps/web is not a standalone application、dsh web、window.__DSH_BOOT__,最后断言那个 marker 文件不存在——断言消息原文写的是 Vite 在拒绝独立 serve 模式之前调用了 Server.listen。子进程的 timeout 设成 10_000。
进程超时不等于快速失败,进程退出后端口空着也不能证明这个端口从没被绑定过。这两句是复盘文档「教训」段的原话,测试是照着这两句写的。
第四处:让当前 URL 对模型可见
前三处解决的是「别走错路」,这一处解决的是「知道自己在哪」。
glue 代码在 packages/bundle/web-app/src/index.ts。它注册了一个名为 app:web-surface 的系统提示词区段,order 是 -98;文本由文件里的 webSurfacePrompt() 生成,开头就是「你正在通过位于 apps/web 的 Vite 入口能构建 shell 但不是独立应用;除非用户要求,否则不要起替代服务器,确需起就用受管的后台任务并验证它确切的 URL。
同一个文件还往 shellEnv 里注册了一个变量组 web-runtime,里面只有一个变量 DSH_WEB_URL,描述是「服务本会话的 DeepSeek Harness Web GUI 的规范本地 URL」。URL 由 localWebUrl() 拼出来,host 部分是文件里写死的 LOOPBACK_HOST = '127.0.0.1'(注释明说这是 webserver schema 的显示用镜像,真值以 schema 为准),端口取 webServer 服务的 port。
端口从哪来?packages/bundle/web-app/cordis.patch.yml 的 webserver 行给的部署兜底是 host: 127.0.0.1、port: 3080——这是默认值,实际端口取决于 --port 和这一行的组合,复盘里那个 3081 就不是它。
这套上下文还能关。同文件 Config 里的 surfaceContext 默认 true,注释写明「一次性的非交互层可以在其用户根本不在 GUI 里时关掉它,否则这段定位文本就是假的」;cordis.patch.yml 里 web-runtime 行的注释另外写明,一个完整的 agent-preset persona 会抑制该 agent 的这段提示词区段,但保留宿主拥有的 shell 变量。所以你要是发现模型不知道自己在哪个 URL 上,先确认这两个开关,而不是先怀疑注入链路。
两处文档口径与当前代码对不上
第一处:复盘文档「已添加的防护措施」第一条写的是启动器在 app:web-surface 区段和受管的 $DSH_WEB_URL/$DSH_WEB_MODE 环境变量中发布规范 URL 和「实际的生产/开发模式」。但当前快照的 packages/bundle/web-app/src/index.ts 只注册了 DSH_WEB_URL 一个变量,全仓库搜 DSH_WEB_MODE,命中的只剩注释和这份复盘:cordis.patch.yml 里那条注释、三个 agent-preset 的 agent.cordis.yml 注释,以及 .agents/notes/implemented/architecture/2026-08-11-cmdline-seam-trim.md —— 后者第 15 行明写 --dev 连同 web runtime 的 mode 配置、按模式分叉的提示词约定和 DSH_WEB_MODE bash 变量一并删除。顺带一提,那条注释里指向的 apps/cli/src/web.ts 在本快照的 apps/cli/src/ 下也找不到。两处不一致,以我们实读的源码为准;说到这里就停。
第二处:复盘文档同一段提到 dsh web --dev 只挂载 HMR 接收端。当前 packages/bundle/web-app/src/startup.ts 里的 web flag 家族是 --host、--port、--trusted-host 三个,没有 --dev;命令自称的名字是 dsh --profile web,而 README 第 20、34 行写的是 npx @deepseek-ai/dsh web 和 pnpm dsh web——apps/cli/src/args.ts 第 13 行的注释说明 web 是 --profile web 的硬编码别名。
HMR 那条链现在是常开的
cordis.patch.yml 的 client-hmr 行注释写明它无条件挂载,在真正有重建 watcher 改写客户端 bundle 之前一直空闲。宿主侧在 packages/client/hmr/src/index.ts,Config 只有一个 pollIntervalMs,schemastery 的 .default(500),注释说这个值对齐了构建侧 watcher 的轮询默认值;文件头部注释说明轮询是设计选择,理由是网络挂载不产生 inotify 事件。再强调一次:500 是默认值,不是「你用起来会怎样」的保证。
改写 bundle 的那个 watcher 是根 package.json 里的 dev:web 脚本,内容是 tsx scripts/dev-web.ts --poll。scripts/dev-web.ts 里 discoverPluginDirs() 用 packages/*/*/package.json 这个两层 glob 扫描,挑出 package.json 里 dsh.client.platform 等于 web 的包。我们按同一判据在这个快照上数了一遍:命中 39 个;packages/*/* 下的 package.json 总数是 219 个。注意 packages/ 下第一层是分组目录(client/、host/、bundle/、web/ 之类),别把分组数当包数。
顺手记一条安全边界
startup.ts 的解析动作里有一段硬拒绝:--host 0.0.0.0 会直接报错退出,错误原文说这是出于安全考虑「尚未支持」,因为那会把远程代码执行暴露到网络上,让你改用 127.0.0.1。--trusted-host 则是往 /api 的浏览器信任围栏里加授权方,packages/bundle/web-app/src/index.ts 里的 resolveLanTrust() 会在绑定全部网卡时采样一次 LAN 的 IPv4 字面量,注释解释了为什么派生条目是不带端口的 IP 字面量。
这些是这个项目自己划的边界,不代表你把它跑起来就安全——它本来就在你本机执行工具、跑 shell、起子进程。
什么情况说明你遇到的不是这个问题
最后一步别省。如果你观察到「改了不生效」,但满足下面任意一条,那它跟这条链路无关:
- 你压根没走 Web profile(比如在 TUI 或 headless 下),
app:web-surface区段和DSH_WEB_URL就不会注册,缺它是正常的; - 你改的是客户端插件包,
pnpm run dev:web也在跑,但你期待的是 shell 或普通包的改动自动生效——按webSurfacePrompt()里的原话,除客户端插件之外的改动都需要重新构建产物并刷新页面; - 你的构建根本没产出 dist:
packages/bundle/web-app/src/index.ts的resolveDistIndex()走require.resolve('@deepseek-ai/dsh-web-frontend/dist/index.html'),失败时抛的是「frontend dist not built; run pnpm run build from the repository root first」,这是另一个故事。
顺带一句通用做法(不是该项目文档内容):Windows 上用 PowerShell 读环境变量是 $env:DSH_WEB_URL,跟 POSIX shell 的 $DSH_WEB_URL 写法不同,验收脚本跨平台复制粘贴时容易在这里翻车。
这次事故留下的最实用的一条,其实是那句「验收要指名确切的 origin,并从外部观察请求的改动是否在那个 origin 上生效」。构建成功、HTTP 200、启动 manifest 存在——三个都不能替代它。
本文依据 DeepSeek Harness 官方仓库(github.com/deepseek-ai/deepseek-harness)的 README、docs/ 下的
架构与子系统文档、以及 packages/ 下的源码整理,核对日 2026-08-17,对应仓库快照 47f9438(版本 0.1.0-rc.5)。
本文内容为仓库源码与文档口径,我们没有安装、也没有运行过这个项目,
因此不涉及界面外观、操作手感与运行速度的任何描述。
该仓库 README 自述处于开发者预览阶段并明确说明未来会有破坏兼容性的变更,
文中出现的命令、配置与默认值随时可能变动,请以仓库最新内容为准。
安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。