问题概述
当用户要求 Agent 抓取一个 React/Vue 等客户端渲染(SPA)站点 的内容时,Agent 会陷入无限循环,连续 20 轮迭代全部产出 assistant_chars: 0,最终以 usage: null + 静默 SEND_END 收尾,用户收不到任何回复。
复现场景:
"请你从这个网址上把所有的赛题信息爬取下来:https://competition.ai4s.com.cn/race/7/description"
该站点是 React SPA,服务端所有路径均返回同一个 HTML 空壳(8 字节有效内容),web_fetch 无法拿到任何实质数据。
根因分析(4 个独立缺陷)
缺陷 1:web_fetch 无法识别 SPA 空壳,且不给模型任何提示
文件:src/tools/web-fetch.js
当前实现是纯 node:http/https GET 请求,遇到 SPA 站点时:
- 服务端返回 HTTP 200 + 极短 HTML(例如
世界科学智能大赛-->\n-->-->)
htmlToText() 处理后得到几乎空白的字符串
- 工具仍返回
ok: true,不附加任何警告
- 模型看到的结果是「成功了,但内容为空」,因此继续尝试不同路径,形成循环
日志证据:
[TOOL_RESULT] {"name":"web_fetch","ok":true,"output":"…世界科学智能大赛-->\n-->-->"}
[TOOL_RESULT] {"name":"web_fetch","ok":true,"output":"…世界科学智能大赛-->\n-->\n(cached)"}
同 URL 二次命中缓存还返回 ok:true,模型没有收到任何"这个页面需要 JS 渲染"的信号。
需要修复:
在 fetchAndExtractText() 函数中,当提取出的纯文本长度 < 可配置阈值(建议 200 字节),且原始 HTML 中存在 SPA 特征标记(<div id="app"、<div id="root"、__NEXT_DATA__、window.__INITIAL_STATE__ 等),在返回的 body 头部追加明确警告:
⚠️ SPA 检测:该页面需要 JavaScript 才能渲染内容。web_fetch 无法执行 JavaScript,获取到的内容为空壳。
建议改用 web_search 搜索相关内容,或请求用户提供页面截图/复制的文本。
缺陷 2:Agent Loop 的同域名重复调用检测缺失
文件:src/chat/agent-loop.js
当前 recentToolSig 机制记录的是完整工具签名(工具名 + 参数)用于检测完全相同的调用。但在本 bug 场景中,模型每次尝试的是不同路径(/api/race/7、/api/v1/race/7、/prod-api/race/7、/race/7/introduction……),属于同域名下的枚举探测,现有机制无法触发。
日志证据:
iter 4: web_fetch /api/race/7
iter 4: web_fetch /api/race/7/detail
iter 5: web_fetch /race/7/introduction
iter 5: web_fetch /api/v1/race/7
iter 6: web_fetch /race/8/introduction
iter 9: web_fetch /sais-competition-web.ai4s.com.cn/api/race/7
iter 11: web_fetch /static/js/main.js ← 连 JS bundle 都试了
14+ 次 web_fetch,全部返回相同空壳,从未触发任何限速或收敛。
需要修复(在 agent-loop.js 的工具调用结果处理段):
增加同域名失败计数器 domainFailCounts: Map<hostname, number>,阈值建议 3 次。当同 hostname 连续返回空内容达到阈值时,向 run.messages 注入:
<system-reminder>
web_fetch 已对 {hostname} 返回空内容 {N} 次。
该站点极可能是 SPA(客户端渲染),继续尝试该域名的不同路径不会有效。
请立即停止对该域名的尝试,基于目前已获取的信息给用户一个最终答复。
</system-reminder>
缺陷 3:连续 assistant_chars: 0 无收敛保护
文件:src/chat/agent-loop.js
当前循环监控的是工具调用的重复签名和写操作失败数,但没有监控模型是否在连续多轮中不产出任何 assistant 文字。在本 bug 中,整整 20 轮迭代 assistant_chars 全为 0,只有 reasoning_chars,意味着模型一直在"思考"但从未向用户输出任何内容。
需要修复:
增加空输出连续计数器 consecutiveEmptyOutputIters(阈值建议 5)。在每次 ITER_END 后,若 assistantText.length === 0 && toolCalls.length === 0,递增计数器;达到阈值时注入强制收敛 system-reminder,要求模型立即给出最终回复。
缺陷 4:流异常结束(usage: null + 空输出)无兜底回复
文件:src/chat/agent-loop.js
日志 iter 20 末尾:
[ITER_END] {"assistant_chars":0,"reasoning_chars":2363,"tool_calls":0,"usage":null}
[SEND_END] {"iters":19,"asst_chars":0}
usage: null 表明这一轮流被异常中断(达到 token 上限、网络中断或模型超时),此时 assistantText 为空且没有工具调用,循环直接退出,用户什么都看不到。
需要修复:
在 streamChat 返回后,检测 !usage && assistantText.length === 0 && toolCalls.length === 0 && reasoningText.length > 0 组合,向用户显示截断提示,并推送降级 assistant 消息保持会话连续性,然后 break 退出循环。
附加建议
建议 A:System Prompt 加「工具失败时的收敛约束」
文件:src/prompts/system.js,在工具使用原则部分追加:
- If web_fetch returns near-empty content from the same domain more than twice,
the site almost certainly requires JavaScript rendering. Stop fetching that domain.
Switch to web_search or summarize based on available information.
- When you have sufficient information to answer the user's question, reply immediately.
Do NOT continue calling tools "just to be thorough".
建议 B:web_fetch 结果缓存附加内容质量标记
在 tool-executor.js 的 CACHEABLE 缓存层,对 web_fetch 的空壳缓存结果附加 isSpaShell: true,使后续命中缓存时自动附带 SPA 警告。
受影响文件
| 文件 |
修改类型 |
src/tools/web-fetch.js |
添加 SPA 空壳检测 + 警告输出 |
src/chat/agent-loop.js |
添加同域名失败计数、连续空输出收敛保护、流异常兜底 |
src/prompts/system.js |
追加工具失败收敛原则 |
src/chat/tool-executor.js |
(可选)缓存层附加质量标记 |
验收标准
- 对任意 React/Vue SPA 站点执行
web_fetch,工具结果中应包含 ⚠️ SPA 检测 警告文本
- 同一 hostname 连续 3 次返回空内容后,Agent 循环日志中应出现
DOMAIN_FAIL_CONVERGENCE_NUDGE,且下一轮模型不再调用该域名的 web_fetch
- 连续 5 轮
assistant_chars: 0 后,日志中应出现 EMPTY_OUTPUT_CONVERGENCE_NUDGE,且模型在下一轮产出 assistant 文本
- 流异常结束(
usage: null + 空输出 + 有 reasoning)时,用户界面应显示截断提示,不得静默退出
- 以上场景全程迭代次数应不超过 8 轮(当前为 20 轮)
优先级
P0 — 当前任何涉及 SPA 站点的抓取请求都会导致用户完全没有输出,属于功能级失效。
问题概述
当用户要求 Agent 抓取一个 React/Vue 等客户端渲染(SPA)站点 的内容时,Agent 会陷入无限循环,连续 20 轮迭代全部产出
assistant_chars: 0,最终以usage: null+ 静默SEND_END收尾,用户收不到任何回复。复现场景:
该站点是 React SPA,服务端所有路径均返回同一个 HTML 空壳(8 字节有效内容),
web_fetch无法拿到任何实质数据。根因分析(4 个独立缺陷)
缺陷 1:
web_fetch无法识别 SPA 空壳,且不给模型任何提示文件:
src/tools/web-fetch.js当前实现是纯
node:http/httpsGET 请求,遇到 SPA 站点时:世界科学智能大赛-->\n-->-->)htmlToText()处理后得到几乎空白的字符串ok: true,不附加任何警告日志证据:
同 URL 二次命中缓存还返回
ok:true,模型没有收到任何"这个页面需要 JS 渲染"的信号。需要修复:
在
fetchAndExtractText()函数中,当提取出的纯文本长度 < 可配置阈值(建议 200 字节),且原始 HTML 中存在 SPA 特征标记(<div id="app"、<div id="root"、__NEXT_DATA__、window.__INITIAL_STATE__等),在返回的body头部追加明确警告:缺陷 2:Agent Loop 的同域名重复调用检测缺失
文件:
src/chat/agent-loop.js当前
recentToolSig机制记录的是完整工具签名(工具名 + 参数)用于检测完全相同的调用。但在本 bug 场景中,模型每次尝试的是不同路径(/api/race/7、/api/v1/race/7、/prod-api/race/7、/race/7/introduction……),属于同域名下的枚举探测,现有机制无法触发。日志证据:
14+ 次
web_fetch,全部返回相同空壳,从未触发任何限速或收敛。需要修复(在
agent-loop.js的工具调用结果处理段):增加同域名失败计数器
domainFailCounts: Map<hostname, number>,阈值建议 3 次。当同 hostname 连续返回空内容达到阈值时,向run.messages注入:缺陷 3:连续
assistant_chars: 0无收敛保护文件:
src/chat/agent-loop.js当前循环监控的是工具调用的重复签名和写操作失败数,但没有监控模型是否在连续多轮中不产出任何 assistant 文字。在本 bug 中,整整 20 轮迭代
assistant_chars全为 0,只有reasoning_chars,意味着模型一直在"思考"但从未向用户输出任何内容。需要修复:
增加空输出连续计数器
consecutiveEmptyOutputIters(阈值建议 5)。在每次ITER_END后,若assistantText.length === 0 && toolCalls.length === 0,递增计数器;达到阈值时注入强制收敛 system-reminder,要求模型立即给出最终回复。缺陷 4:流异常结束(
usage: null+ 空输出)无兜底回复文件:
src/chat/agent-loop.js日志 iter 20 末尾:
usage: null表明这一轮流被异常中断(达到 token 上限、网络中断或模型超时),此时assistantText为空且没有工具调用,循环直接退出,用户什么都看不到。需要修复:
在
streamChat返回后,检测!usage && assistantText.length === 0 && toolCalls.length === 0 && reasoningText.length > 0组合,向用户显示截断提示,并推送降级 assistant 消息保持会话连续性,然后break退出循环。附加建议
建议 A:System Prompt 加「工具失败时的收敛约束」
文件:
src/prompts/system.js,在工具使用原则部分追加:建议 B:
web_fetch结果缓存附加内容质量标记在
tool-executor.js的CACHEABLE缓存层,对web_fetch的空壳缓存结果附加isSpaShell: true,使后续命中缓存时自动附带 SPA 警告。受影响文件
src/tools/web-fetch.jssrc/chat/agent-loop.jssrc/prompts/system.jssrc/chat/tool-executor.js验收标准
web_fetch,工具结果中应包含⚠️ SPA 检测警告文本DOMAIN_FAIL_CONVERGENCE_NUDGE,且下一轮模型不再调用该域名的web_fetchassistant_chars: 0后,日志中应出现EMPTY_OUTPUT_CONVERGENCE_NUDGE,且模型在下一轮产出 assistant 文本usage: null+ 空输出 + 有 reasoning)时,用户界面应显示截断提示,不得静默退出优先级
P0 — 当前任何涉及 SPA 站点的抓取请求都会导致用户完全没有输出,属于功能级失效。