Agent的取消与错误恢复——DeepSeek Harness的一次调用一次尝试,以及等待静止而不是竞速抛弃

做语音陪伴产品,取消是家常便饭。用户一张嘴说话,Agent 就得立刻停下来,不管它是在想、在调工具,还是在说话。

可"停下来"远比看起来难。停得不干净,旧轮次的消息会迟到,混进新的轮次里;被"放弃"的请求其实还在跑,连接一直不释放。重试也有类似的问题,两层重试叠在一起,一次失败就能放大成一场重试风暴。

DeepSeek Harness(dsh)在取消和重试上的设计非常克制。这是 dsh 系列的第五篇,把模型适配层、重试和取消放在一起看。

一、适配器只报告事实

dsh 把所有的模型供应商都收敛到一个很窄的接口上:给一组参数,返回一个流。

class MyAdapter extends LlmAdapter {
  async *stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
    /* … */
  }
}

接口很简单,但每个适配器都必须遵守一组协议义务。我挑几条最有意思的:

  1. 用量(usage)必须在结束(finish)之前发出,结束之后什么都不能再发;
  2. 工具调用的参数端到端保持为原始的 JSON 字符串,不要中途解析;
  3. 错误只有两条路径:传输或协议层的错误从 stream() 抛出,供应商在流里返回的错误则以一个带失败信息的结束事件收尾,两条路径用同一种失败类型;
  4. 一次适配器调用,就是一次供应商尝试,SDK 里自带的重试必须关掉;
  5. 流的空闲超时必须真正中止传输,而不只是 reject 一个 promise;
  6. 上下文溢出只有一个规范的错误码 CONTEXT_WINDOW_EXCEEDED,消费者按错误码路由,绝不去解析供应商返回的错误文本;
  7. 正常结束、但一个内容块都没有,也算一种可重试的错误。

其中第 4 条最关键。为什么要关掉 SDK 的重试?两个原因:一是 SDK 的重试次数会和 agent 自己的重试次数相乘;二是 SDK 内部的那些中间失败,在 agent 的日志里完全看不见。

适配器是翻译官,不是决策者。它只负责把供应商的情况如实翻译过来,包括 HTTP 状态码、Retry-After、请求 id,至于要不要重试,不归它管。

二、重试是策略,不是事实

既然适配器只报告事实,那失败信息长什么样?

interface LlmFailure {
  message: string
  code: string
  status?: number
  providerRetryAfterMs?: number
  requestId?: string
}

注意这里刻意没有 retryable 字段。"能不能重试"是一个策略判断,不是一个事实。把它塞进失败信息里,就等于把策略嵌进了事实。

真正做重试决策的,是一个挂在 agent/request-error 上的插件。它的规则大致是:

  1. 只有少数几种瞬时错误默认重试:限流、服务端错误、超时、传输错误、空响应;鉴权失败、配额用尽、非法请求、上下文溢出这些,都不重试;
  2. 退避从 500ms 开始,上限 10 秒,加 10% 的抖动;
  3. 供应商给了合法的 Retry-After,并且没超过上限,就按它来;超过了上限,就把决定交给下游,绝不早于供应商的指示去重试;
  4. 每次退避睡眠之前,先往日志里追加一条 llm/retry 事件,记下这是第几次、等多久、因为什么失败。

第 4 条很贴心。没有这条记录,用户看到的就是 agent "卡住了";有了它,"正在退避"就成了一个可以被观察到的持久状态。

还有一点,重试、上下文溢出后的压缩恢复,这些恢复插件各自持有一个有限的预算。所以一个请求最多会被发送多少次,是可以算出来的:1 加上各个恢复插件的预算之和。

失败的尝试也会被记录,但只记成 assistant/attempt,不会产生一条助手消息,也不会派发工具。重试在同一个 turn、同一个 step 里,从持久化的历史重新构建请求。所有的预算都用完了,最终的失败只在 turn/end 里记录一次。

只让一层负责重试,并且这一层的每一次尝试都要进日志。

三、每个 turn 一个取消持有者

再看取消。dsh 的设计是:每个 turn 有且只有一个取消的持有者,也就是一个 AbortController,下一个 turn 拿到的是一个全新的信号。

这个信号会被显式地传给 turn 里的每一个扩展点:pre-step、提示词组装、请求、模型流、错误恢复、工具、审批、turn-stopping、子代理。

取消的原因是一个封闭的类型联合:

type AgentCancelCause =
  | { readonly kind: 'user' }
  | { readonly kind: 'parent' }
  | { readonly kind: 'hook'; readonly reason: string }
  | { readonly kind: 'disposed' }

agent.cancel({ kind: 'user' }) // 清空队列,中止当前 turn
agent.cancel({ kind: 'user' }, { keepInbox: true }) // 只中止当前 turn,保留排队的输入

但持久化的时候,日志里只记粗粒度的结局:这个 turn 被中止了。谁请求的取消、错误堆栈、自由文本的原因,都不写进持久事件。turn 的结局是事实,谁发起了取消是请求,两者分开。

还有一个设计决定很值得琢磨:取消信号不放进 AsyncLocalStorage。

AsyncLocalStorage 会跟着异步的后代一直传下去,贯穿整个驱动器的生命周期,而取消权只属于某一个 turn。如果放进去,某个泄漏出去的回调在下一个 turn 里还能拿到上一个 turn 的过期信号。显式传参确实啰嗦一点,但取消权的生命周期一目了然。

对应到 Python,AsyncLocalStorage 就相当于 contextvars。道理是一样的。

四、协作式取消:等待静止,不竞速抛弃

取消最容易犯的错,是用一个竞速,把还在跑的工作直接丢掉:

# 反例:超时就不管了,但 task 其实还在跑,连接也还占着
done, pending = await asyncio.wait({task}, timeout=3)
if pending:
    return "已取消"

dsh 明确禁止这么做。取消是协作式的:发出信号以后,要等已经启动的工作真正停下来。如果某个工具实现得不合作,不响应信号,那它的调用就一直挂着。dsh 认为这是一个诚实的状态:宁可让它显示为未完成,也不要谎报"已经空闲"。

另一个细节是,被取消的工具调用,结果码要区分清楚有没有可能产生副作用:

取消发生的时机结果码可能有副作用吗
进入工具体之前就已经取消ABORTED_BEFORE_DISPATCH否
权限检查、审批期间被取消ABORTED_BEFORE_DISPATCH否
工具体已经在运行ABORTED是
超时策略到期TOOL_TIMEOUT是

一个 write 被取消了,它到底写没写?看结果码就知道。BEFORE_DISPATCH 说明工具体根本没被调用,不用去检查文件;ABORTED 说明可能写了一半,需要检查外部状态。有没有副作用,是第一等的信息。

最后,任何失败的路径都要把工具调用和结果"配平"。模型发出了调用但还没派发就被取消了,也要给每个调用补一个"被中止"的结果。历史里永远不会留下没有结果的孤儿调用。

五、对照我们的语音产品

1. 插话即取消,旧轮作废

我们的底座里,用户插话就取消当前的轮次,取消之后,旧轮次如果还有迟到的消息回来,比如模型流的尾巴、TTS 的音频片段,按轮次号作废。

这和 dsh "每个 turn 一个全新的信号"其实是同一个思路:轮次号就是我们的信号。旧轮次的东西带着旧的轮次号回来,一比对就知道该扔掉。

2. 取消信号要传到每一个下游

语音场景下,一个轮次里同时开着好几条流:ASR 的识别流、LLM 的生成流、TTS 的合成流。取消的时候,每一条都得真的关掉。

按 dsh 的思路,取消信号应该显式地传给每一个下游,而不是靠一个全局的"当前轮次"变量。我们的代码大致是这样组织的:

class Turn:
    def __init__(self, turn_id: int):
        self.turn_id = turn_id
        self.cancelled = asyncio.Event()  # 每个轮次一个新的取消信号
        self._tasks: set[asyncio.Task] = set()

    def spawn(self, coro) -> asyncio.Task:
        task = asyncio.create_task(coro)
        self._tasks.add(task)
        return task

    async def cancel(self) -> None:
        self.cancelled.set()
        for task in self._tasks:
            task.cancel()
        # 等待静止:所有已启动的任务都真正结束,连接才会被释放
        await asyncio.gather(*self._tasks, return_exceptions=True)

信号作为 Turn 对象显式地传给 ASR、LLM、TTS 的调用,而不是放在 contextvars 里。

3. 等待静止

我们压测的时候,定位过连接泄漏和线程池耗尽的问题。其中有一部分,就是这种"以为取消了,其实还在跑"的请求:上层已经返回了,底下的 HTTP 连接、WebSocket 却还挂着,一直占着连接池。

上面那段代码里最关键的就是最后一行:取消以后要 await 所有任务真正结束,而不是发出取消就返回。

4. 不要叠两层重试

我们的模型是经 LiteLLM 接入的,它自己就支持重试。早期有一段时间,我们在外面又包了一层重试。结果一次上游的抖动,内层重试几次、外层再重试几次,请求量直接翻了好几倍,反而加剧了限流。

dsh 的"一次调用一次尝试"就是在防这个。现在我们只保留一层,由这一层负责退避和降级,并且每一次尝试都记进 trace。

5. 打断以后,历史里只留用户听到的

最后一个坑在上一篇日志的文章里提过:用户打断时,模型已经生成了一大段,但用户只听到了一部分。取消的时候,要把"用户实际听到的部分"作为这一轮的结果,而不是把生成的全文写进历史。

总结

  1. 适配器只报告事实,一次调用就是一次供应商尝试,SDK 的内置重试要关掉,否则次数相乘、中间失败不可见;
  2. 能不能重试是策略,不是事实,只让一层负责重试,每一次尝试和每一次退避都要进日志;
  3. 每个 turn 一个取消持有者,信号显式传给所有扩展点,不放进 AsyncLocalStorage 或 contextvars;
  4. 取消是协作式的,要等待静止,而不是竞速抛弃,否则就会泄漏连接和资源;
  5. 用结果码区分"可能有副作用"和"从未派发",任何失败路径都要把工具调用和结果配平。

相关资料