约 9 分钟 · 架构调试 · 案例环境为 Antigravity CLI 1.3.4/1.3.5 与 Paseo 客户端

读完你能: 理解长会话流式交互中历史错误跨轮污染的发生机制;认识到单段比较在多工具多回复轮次中的局限与修复;了解后台自动升级对哈希守卫的影响,以及在不篡改数据库的前提下构建兼顾安全与可用的受限兼容层。

1. 现象:明明协议步骤完成,CLI 却报历史流中断

在以流式协议(如 stream-json)驱动的 AI 智能体架构中,外层客户端(Paseo)通过命令行调用底层引擎(Antigravity CLI),实时接收进度事件并在轮次结束时获取终态。

长会话中曾出现前置流中断(触发诱因未查明);后续正常交互中,尽管智能体完整输出回复,前端界面每轮结尾仍弹出红色错误提示:

The stream was interrupted. Please continue the task you were working on.

捕获底层协议流事件发现:用户输入(user_input)、系统上下文(system_message)与智能体响应(agent_response)步骤均正常流式生成并标记为 DONE。需要说明的是,DONE 仅表示协议层该步骤执行完毕,并不自动证明全部业务逻辑成功。然而紧随其后的终态 result 事件中,status 却返回 ERROR,附带错误文本正是既往历史中的流中断提示。

由于客户端直接以 result.status 判定任务成败,导致当前轮次即使步骤完整,也被系统误判为“发生中断”,智能体无法正常转入空闲(idle)态。

2. 定位与证据:从日志假说收敛到真实协议现象

排查初期,最容易被伴随的背景日志带偏。语言服务日志中曾观察到周期性警告:

summaries_store: failed to load external trajectory

日志显示系统试图读取 Protobuf 文件(.pb),而磁盘上仅存在同名 SQLite 数据库(.db、.db-wal)。这容易让人推测是否格式不兼容引发了流中断。但对比实验表明:

  • 独立会话对照:在相同环境新建独立会话(会话身份与历史不同),执行相同输入能稳定返回 result.status = "SUCCESS"。
  • SQLite 数据库只读核验:只读检查异常会话的 steps 表,早期特定步骤确实记录了错误类型(step_type = 17)的流中断信息;但当前新轮次的所有步骤全为正常输入与响应,无任何错误记录,状态均为完成(status = 3)。这里仅核对了相关步骤与历史前缀,不宣称整个库所有历史数据均被完整检验。
  • 上游版本与更新机制:排查初运行升级检测,CLI 报告已是最新;随后后台 auto_updater 将二进制由 1.3.4 升级为 1.3.5(经升级日志与版本号核对)。两版本中该跨轮现象均客观存在。

对 .pb/.db 假说,现有证据仅表明其“未被证实为根因”,未证实不等于已证伪。因 CLI 属闭源编译分发,无法断言具体内部汇总查询或源码行。“历史错误跨轮污染”是运行证据支持的技术解释,非源码级既成事实。 此外,排查中 Paseo 旧连接曾报 kill EPERM,经官方 agent reload 重连原 Agent 恢复,深层系统权限细节未证实,不作无据推测。

3. 跨轮状态边界:为什么不能直接把历史错误带入当前轮

在多轮对话状态机中,“轮次”(Turn)应当具备明确的生命周期边界。历史失败是不可篡改的事实记录;但新一轮开启后,当前轮成败应仅由本轮内的步骤、异常及信号决定:

[历史轮次 (曾发生中断)]     -->   [新轮次 (正常执行)]
  步骤: user -> error            步骤: user -> agent_response
  持久化: step_type=17 (ERROR)   持久化: step_type=15 (DONE)
            |                              |
            \--------- 错误穿透 -----------> 终态汇总误读: ERROR (假阳性)

若状态汇总模块在轮次结束时将未清理的历史错误引入当前终态,历史中断就会持续污染后续正常轮次。对此,两种粗暴解法均不可取:

  • 盲目捕获降级:在外层将所有 ERROR 强行改为 SUCCESS,会静默掩盖模型崩溃、工具报错或主动取消等真实故障。
  • 直接篡改数据库:通过 SQL 删除或修改历史错误步骤,会破坏审计轨迹,甚至引发文件锁冲突。

合理方案必须在不碰数据库、不掩盖真错的前提下,划定精确的当前轮判定边界。

4. 受限修复与多版本取舍:全轮拼接与指纹绑定

我们在本地实现了一个受限兼容启动器(compat_launcher.py)。但在演进中经历了“初步对账 → 复杂长任务失败保守回退 → 发现拼接根因并修复”的过程。

初期逻辑简单假设当前轮仅一个响应步骤,直接拿最后一段 agent_response 文本与终态 result.response 比对。单文本回复轮次中该逻辑能正常纠错;但在真实长任务(智能体先输出前置确认,调用多个只读工具,最后输出完成确认)中,一轮包含多个 agent_response。

抓包分析表明:CLI 的 result.response 实际上是整个当前轮次内所有 agent_response 步骤文本按 step_index 升序连接而成的完整字符串。先前只比对最后一段文本导致校验失败;启动器按安全底线 fail-closed 拒绝纠偏,导致长任务再次误判为流中断。

同时,后台 auto_updater 升级二进制也让初始单版本哈希守卫按设计退出——证明单改 Python 包装器并不代表兼容层生效,二进制哈希改变会直接透传原程序。

针对这两处缺口,启动器进行了针对性重构:

  1. 多版本指纹白名单:仅登记实核字节的 1.3.4 与 1.3.5 哈希,并在结束时核验 binary_digest() == launch_digest。未知后续版本默认不兼容并透传原程序;独立二进制快照与运行文件分开保留,不关闭官方自动更新。
  2. 全轮多段文本聚合:实现聚合函数,提取当前轮全部响应文本并按步号拼接后再行对账。
  3. 目标会话严格限定:仅针对排查确诊的特定会话 ID 与 stream-json 格式生效。

简化后的全轮对账示意代码如下:

# 简化示意代码(反映全轮聚合与完整前置判断条件)
def response_text(text_by_step):
    """按 step_index 升序拼接当前轮全部 agent_response 文本"""
    return "".join(text_by_step[i] for i in sorted(text_by_step))

def may_correct(result, rows_before, rows_after, observed, turn_response, interrupted, binary_ok):
    if interrupted or not binary_ok or not isinstance(result, dict):
        return False
    # 严格限定目标会话、特定错误文本,且响应文本必须非空且严格等于全轮拼接文本
    if (result.get("conversation_id") != TARGET_CONVERSATION
            or result.get("status") != "ERROR"
            or result.get("error") != EXPECTED_ERROR
            or not isinstance(result.get("response"), str)
            or not result["response"].strip()
            or result["response"] != turn_response):
        return False
    # 历史前缀中必须真实存在该错误记录
    if not rows_before or not any(typ == 17 and EXPECTED_ERROR.encode() in (p or b"") for _, typ, _, p in rows_before):
        return False
    boundary = rows_before[-1][0]
    prefix = [r for r in rows_after if r[0] <= boundary]
    current = [r for r in rows_after if r[0] > boundary]
    if prefix != rows_before or not current:
        return False
    # 当前轮次必须以唯一用户输入开启、完成响应结尾,且无错误步骤
    if (current[0][1] != 14 or sum(r[1] == 14 for r in current) != 1
            or any(typ == 17 or status != 3 for _, typ, status, _ in current)
            or current[-1][1:3] != (15, 3)):
        return False
    # 协议流事件与持久化步骤索引及类型必须完全匹配且均为 DONE
    if {idx for idx, _, _, _ in current} != set(observed.keys()):
        return False
    for idx, typ, _, _ in current:
        s = observed[idx]
        if s.get("state") != "DONE": return False
        if typ == 14 and s.get("step_type") != "user_input": return False
        if typ == 15 and s.get("step_type") != "agent_response": return False
    return True

5. 验证、回退与启示:从测试漏测到真实长任务收敛

为防类似漏测,离线单元测试用例从 9 项扩展为 13 项,重点补齐多段回复、顺序乱序与多版本哈希匹配场景:

测试场景分类 模拟输入条件 预期判定 实际行为
标准纠正用例 历史有中断错误,当前轮所有事件及落库均为 DONE,文本匹配 True 状态校正为 SUCCESS,清除假错
多段回复拼接回归 单轮产生多段回复与工具调用,聚合拼接后与终态匹配 True 修复后正确纠错,消除长任务误报
多段回复文本错漏 多段回复少算或内容篡改,与终态字符串不一致 False 拒绝校正,保持 ERROR
取消标志防护 传入 interrupted=True 取消标志(纯函数对比) False 拒绝校正,保持 ERROR
真实当前错误 当前轮次数据库中新增了 step_type=17 错误步骤 False 拒绝校正,真实错误完整暴露
工具未完成/失败 当前轮工具调用未返回或处于非成功状态(状态非 3) False 拒绝校正,防止虚假通过
会话/报错不匹配 目标会话 ID 不符,或报错信息非特定流中断文本 False 拒绝校正,原样传递
历史前缀漂移 执行前后历史数据前缀不匹配 False 拒绝校正,防御并发状态污染
协议流漏事件 内存捕获事件与数据库持久化步骤不一致 False 拒绝校正,避免未决状态放行
参数非标透传 非 stream-json 模式或普通命令行查询(如 --version) 豁免 透传原二进制执行
已知版本哈希匹配 注册白名单内的 1.3.4 / 1.3.5 检验 允许 激活受限校验逻辑
未知版本升级失效 出现未注册的后续版本哈希 豁免 自动解除兼容逻辑,直连原程序
响应排序与空文本 乱序接收响应片断及空文本注入 健壮 按索引正确排序且拼接一致

在真实环境中,兼容层完成了多阶段验证:

  1. 基础文本交互:单段响应顺利完成,客户端脱离红标报错转入 idle;
  2. 多工具长任务交互:先输出前置确认,连续触发 3 次真实文件只读工具调用,最后输出完成确认。修复后的全轮对账逻辑精准匹配复合响应,流程完整走完,客户端顺利进入 idle 态且 lastError 完全清空。

需明确的是,这只是针对特定已知版本、特定会话与特定协议的本地临时兼容垫片,不是上游官方修复,亦不代表已彻底解决或证明通用可靠性。从这次排查中可提炼出三条工程启示:

真实协议语义可能与直觉相悖

不要理所当然地认为 result.response 只包含最终结语。在复杂工具调用协议中,状态聚合器往往会将整个会话轮次的碎片拼接输出,测试用例必须覆盖完整的协议实际形态。

状态机的生命周期边界

聚合器与终态判定必须具备明确的轮次上下文,不能将持久化历史无界限地当成运行态的全局错误池。

自动更新与热补丁的冲突管理

当底层依赖具备后台自动升级特性时,任何基于二进制特征的本地兼容层都必须有明确的版本准入与失效安全退出机制,否则很容易因二进制静默变化导致垫片被动退场而不自知。