这次排查开始得很像一次普通的适配器巡检:一个平台已经修好,其他几个浏览器问答入口要不要也检查一下。表面上看,问题只是长答案偶尔返回空,或者在中途停止。短问题却经常正常,所以它很容易被当成网络抖动,或者被归因到某个平台本身不稳定。
我一开始也差点沿着“把超时时间调大”这条路走。后来把已经修好的实现和其他适配器放在一起看,才发现真正的问题不在答案多长,也不在网络快慢,而在于我们把一个本应持续一段时间的工作,硬塞进了一次有固定上限的页面调用里。
短答案为什么会掩盖长任务问题
浏览器适配器的任务通常是:在页面里发起请求,读取流式响应,把不断到来的片段拼成完整答案,再把结果交还给外部调用者。短答案的流很快结束,整段读取循环在运行时限制之前完成,于是看起来一切正常。问题要到答案足够长,或者生成中间有一段停顿时才暴露出来。
当时能看到的现象并不神秘:页面脚本调用等待了一会儿,随后出现固定运行时超时,外部只拿到空结果或不完整内容。浏览器并不是没有继续生成,调用方只是已经失去了等待它的机会。流式输出的持续时间一旦超过单次页面调用的固定寿命,完整答案就不再是一个可靠的返回值。
这里最容易误判的是把“流式”理解成“调用可以无限等待”。实际上,流式只说明数据分段到达,不说明承载读取循环的那次调用可以一直活着。页面运行时、自动化桥接层和外部脚本,各自都有自己的边界;只要最短的那一层先到期,外部就会把一次未完成的工作看成失败。
如果不处理,后果也不只是偶尔少几个字。长问题会被错误地判成平台故障,重试又可能重新发起一次生成,造成重复请求和资源浪费。更糟的是,短问题持续通过会给人一种虚假的安全感,直到真正重要的长回答在最后一段被截断,才发现没有可恢复的中间状态。
真正的根因藏在一次调用里
我把几个适配器的关键代码放在一起比较,而不是先看它们各自的超时参数。典型的旧写法是:外部调用一次页面脚本,脚本在页面内发起请求,然后进入 reader.read() 循环,直到收到结束标记,最后才返回拼好的文本。整个过程都被包在同一次页面运行时调用里。
这段代码在逻辑上并没有错。它能读取流,也能正确拼接内容;错的是生命周期设计。页面调用承担了启动请求、持续消费、判断完成和返回结果这几件不同性质的事。只要其中的消费阶段超过固定上限,前面已经收到的内容也可能随着调用失败而一起丢掉。
这件事让我重新意识到,排查异步问题时,不能只问“循环有没有读到数据”,还要问“谁拥有这段循环的生命周期”。如果读取循环属于一次不能无限延长的远程执行,那么它天然不适合承载长任务。真正要处理的不是某一行 read(),而是启动者和观察者被绑在了一起。
判断长任务是否可靠,第一眼应该看它能否脱离一次调用继续存在,而不是只看短样例能不能返回。
我没有继续加大超时
最容易误判的错误捷径,是只把单次调用的超时时间调大。它也许能让某个测试通过,却没有改变任务仍然被锁在一次页面运行时里的事实。答案再长一些,或者环境里出现一次额外等待,新的上限仍会被撞上;不同适配器的固定边界还可能并不相同,维护者最后只是在追逐一组不断变化的数字。
我选择把启动和收取分开,是因为它直接对应了故障的边界:启动请求只需要很短的页面调用,读取过程则放在页面可持续保存的状态里,外部通过多次短调用观察状态。这样,单次调用只负责做一小件事,长任务的持续时间也不再由某一次 evaluate 的寿命决定。
这个选择并不意味着可以无限等待。相反,分离后更需要明确的状态和边界。页面状态至少要能表示正在运行、已经完成和失败;外部轮询要有空闲看门狗,防止状态长时间不变化,也要有总时限,防止异常页面让任务永远挂着。把边界写出来,才算把“等待”变成了可判断的工作。
把流改造成可恢复的状态机
改造后的流程可以理解成一个很小的状态机。第一次页面调用只启动流,把读取逻辑放进页面侧的持续任务,并立即返回一个已启动的信号。读取到的新片段、当前文本、错误信息和完成标记,都写入一个页面可访问的状态对象。外部脚本随后每隔一小段时间发起一次短查询,读取新增内容或当前终态。
这里的关键不是轮询本身,而是状态成为了任务的中间落点。外部调用即使某一次查询超时,下一次仍可以从页面状态继续观察;页面侧如果已经完成,外部也能通过完成标记收尾,而不用重新触发请求。对于答案文本,可以保存累计内容,也可以保存片段序号,让外部只取增量,避免每次重复搬运整段结果。
我会把终态分成完成、失败和仍在运行,而不是只用“有没有结果”做判断。完成要带完整文本,失败要带可解释的原因,运行中要带最近一次有进展的时间。这样一来,外部流程才能区分“还在生成”和“已经失败但没有出现在成品列表里”。这也是后续设置看门狗的前提。
并发也要放进这个设计里。多个任务可能共享同一个浏览器服务,不能因为单次调用变短,就顺手无限增加并行进程。启动前要确认共享资源是否健康,任务状态要有清楚的归属,失败时要报告发生在哪个阶段。可恢复的状态机不是把等待藏起来,而是把启动、进展、失败和完成都变成外部能够观察的事实。
逐个平台验证,顺手清掉陈旧事实
我没有因为一个平台已经改好,就假设其他适配器也会自动拥有同样的行为。逐个检查后,确实发现有的平台仍把 reader.read() 的完整循环塞在一次页面调用里;也有的平台已经采用了页面内脱离式读取、外部轮询的模式。两类代码外表都叫“流式回答”,但生命周期完全不同。
改造后,验证不能只用一句很短的问题。短问题正是原缺陷最容易隐藏的输入。我使用足够长、要求分段详答的问题,检查调用是否能返回成功状态,任务是否进入完成状态,最后文本是否从开头到结尾完整保留。还要观察长时间没有新增片段时,空闲看门狗是否会收敛到失败,而不是无限等下去。
后续检查又发现一个容易被忽略的尾巴:平台超时表里仍残留已经修复的条目。代码已经走新的启动与轮询路径,配置却还在暗示旧的固定超时风险。如果不清理,下一次排查会把已经解决的平台再次列为高风险,也可能让维护者继续给错误的地方加补丁。
这说明修复并不只发生在代码文件里。配置表、检查清单和记忆中的判断,都属于系统对现实的描述;实现改变后,描述也要跟上。否则系统会同时拥有两套互相矛盾的事实,下一次故障仍会从旧结论出发。
下次我会怎么做
下次遇到浏览器流式回答在长输入下失败,我会先问 3 个问题:完整读取循环是不是被放在一次页面调用里?页面是否保存了可继续观察的中间状态?外部能不能明确区分运行中、完成和失败?这 3 个问题比先调大超时更接近根因。
然后我会把任务拆成启动、状态读取和收尾 3 个阶段。启动调用只做发起和登记,状态读取使用短而可重复的查询,收尾负责完整性检查和清理。每个平台都用长问题实测,并同时检查空闲看门狗、总时限、并发边界和失败原因。验证通过后,再把陈旧的超时表和重复实现一起对齐。
这件事让我重新意识到,真正难的不是让一次流式请求跑通,而是让它在超过一次调用寿命之后仍然有地方继续存在。一个流程成熟的标志,也不是它顺利时能返回答案,而是中途被打断时,下一次还能知道任务走到哪里。
我更愿意把“启动与轮询分离”看成一种工作习惯,而不是某个平台的技巧。凡是需要持续生成、持续下载或持续处理的任务,都不该把全部生命周期押在一次不可延长的调用上。把长工作拆成可观察、可恢复的小状态,下一次面对类似问题时,就不用再从“是不是超时太短”开始猜。