Session 已经持久化了,为什么还需要 Durable Execution?

6,316 字#Agent #rpi #持久化 #架构

假设你让 Agent 往 CHANGELOG.md 追加一条记录。模型发出工具调用,工具改好了文件,进程却在保存工具结果之前退出了。

重启以后,聊天历史还在,模型提出的工具调用也在,只是没有对应的结果。现在应该再执行一次吗?

再执行,文件里可能多出一条重复记录。不执行,又不能直接告诉模型“已经成功”,因为会话记录没有这个证据。

Session 已经持久化,不代表这次执行已经具备可靠的恢复办法。 Durable Execution 要补上的,正是从“记录还在”到“知道怎样继续”这一段。

先看没有中断时,Agent 怎样完成任务

我们把任务稍微展开一点,先看它在没有中断时怎样完成:

给 CHANGELOG.md 追加一行“修复登录超时”,然后运行 pytest -q,最后汇报结果。

下面用这个假设任务贯穿全文。为了容易看懂,先假设只有一个工具调用在执行,没有并行、重试和上下文压缩。append_note 是教学例子里的工具名,不是 rpi 的内置工具;rpi 可以通过 bash 完成相同的文件追加动作。

这里有四个角色:

  • 模型根据输入生成回复,也可以提出工具调用。它不会因为生成了一段调用参数,就亲自修改磁盘文件。
  • 执行器是调用模型、分发工具、接收结果的程序。通常所说的 Agent Loop,主要由它驱动。
  • 工具执行实际操作,例如修改文件、运行测试,再把结果返回给执行器。
  • Session 保存会话历史,让执行器以后能重新取出这些消息。

一次正常执行可以分成几步。执行器先保存用户任务,把任务和已有历史发给模型。假设模型先要求追加记录,执行器保存这条工具调用,再调用 append_note。

工具修改文件后返回“追加成功”。执行器把这个结果保存为一条工具消息,然后再次请求模型。这一次,模型能同时看到原任务、自己先前提出的调用,以及调用结果。它才有依据继续提出“运行测试”。

测试结果返回后,执行器再把它交给模型。模型生成最终汇报,执行器保存回复并结束本次运行。

简化成教学伪代码,就是:

messages = session.load_messages()
messages.append(user_task)
session.save(user_task)

while True:
    reply = model.generate(messages)
    session.save(reply)
    messages.append(reply)

    if not reply.tool_calls:
        break

    for call in reply.tool_calls:
        result = tools.execute(call)
        message = tool_result(call.id, result)
        session.save(message)
        messages.append(message)

这段程序已经保存了用户消息、模型回复、工具调用和工具结果。本文所说的“仅消息持久化”,指的就是这种能力范围,不是一个只存最后答案的简陋聊天记录器。

先记住一个动作:工具返回以后,执行器还要再调用一次模型。模型不是一直悬在那里等工具回来,再从原来的思考位置继续;执行器需要把更新后的消息放进下一次请求。

Session 是持续保留的会话,里面可以有多次用户提问。run 则是执行器为处理一次输入而启动的一轮驱动过程,可能包含多次模型请求和工具调用。会话还在,不代表其中某次运行还在进行。

进程退出以后,究竟留下了什么

我们先看一个容易恢复的位置,暂时把“结果丢失”的难题放一放。假设追加已经成功,工具结果也已保存。进程在下一次模型请求发出之前退出了。

此时会话里有三条业务消息。下面用便于阅读的格式展示,不对应任何供应商的 API 协议:

消息 1:用户任务
  追加“修复登录超时”到 CHANGELOG.md,
  然后运行 pytest -q,最后汇报结果。

消息 2:模型提出工具调用
  call_id: call-9
  tool: append_note
  args:
    path: CHANGELOG.md
    line: 修复登录超时

消息 3:工具结果
  call_id: call-9
  result: 已追加一行

磁盘上的 CHANGELOG.md 也已经多了一行。它和上面的会话消息是两份不同的数据:一份是操作产生的外部效果,一份是执行器保存的结果记录。

消失的是进程内存:while 循环执行到哪里、当前的 messages 变量、等待下一次模型请求的异步任务,都随进程退出而消失。新进程不会自动回到旧循环的下一行。

如果新程序启动后只做这件事:

messages = session.load_messages()
show_history(messages)
wait_for_user_input()

那么消息确实恢复了,任务却停在那里。没有程序去发起下一次模型请求,测试当然也不会自己开始。

这不是说所有 Session 产品都只能等用户输入,而是这段实现没有包含恢复调度。把数据库换掉,或者让历史保存得更完整,都不会自动补上缺少的那次调用。

现在也可以回答一个很直接的问题:既然最后一条是工具结果,启动时一律继续请求模型,不就行了吗?

对这个简单例子,这可以是一条恢复规则。但应用一旦采用了它,就已经开始实现执行恢复。还要处理工具要求结束运行、用户明确取消、任务暂停等情况,不能把所有以工具结果结尾的历史都当作待办任务。

消息当然可以承载这些状态。关键不是必须额外建一张 runs 表,而是程序要有明确依据,区分“可以查看的过去”和“还欠一次执行的工作”。

让新进程接手,需要把记录变成动作

接下来,我们给刚才的程序补上一组运行记录,看看新进程能据此做出什么判断。字段仍是教学示意,暂时不用对照 rpi 的真实存储格式:

run_started
  run_id: run-17
  task: 消息 1

工具调用消息:消息 2,call-9

tool_started
  run_id: run-17
  call_id: call-9
  effective_args: 消息 2 中实际执行的参数
  replay_policy: never

工具结果消息:消息 3,call-9,已追加一行

# 进程在这里退出,没有 run_finished(run-17)

run_started 表示系统开始处理这次任务。正常结束时,执行器会写对应的 run_finished。两者配对后,新进程就能找出没有正常收尾的运行,不用让模型猜“用户是不是还希望我继续”。

call_id 则把调用和结果对应起来。恢复时需要确认“call-9 有结果”,而不只是“历史里出现过某条成功消息”。同一个会话可能修改过很多次文件,它们不能混用结果。

tool_started 保存执行时的参数和重跑策略。这里的 never 表示不能因为结果缺失就自动重跑旧调用。追加不是可以随便重复的动作:同样的调用执行两次,会追加两行。这也叫非幂等操作。

在结果已经保存的这个窗口,恢复步骤并不复杂:

  1. 宿主启动,打开已有 Session,调用恢复程序。
  2. 恢复程序找到没有结束记录的 run-17。
  3. 它按调用 ID 核对消息,发现 call-9 已有结果,不再执行这次追加。
  4. 它确认当前消息允许继续请求模型,把这次运行登记为待续接工作。
  5. 宿主驱动待续接工作,执行器加载消息,发起新的模型请求。

这些步骤都是应用里的程序行为。“恢复器”就是负责检查记录、处理遗留状态并安排后续工作的那部分代码,不是另一个负责猜测进度的模型。

只画这个成功续接分支,时序如下。重点看进程退出后的第一条箭头:新进程先读记录,随后才决定发起什么请求。

追加结果已经保存后进程退出,新执行器读取消息和未结束的运行记录,复用原结果并向模型发起下一次请求

图:教学概念时序,不是 rpi 的实测轨迹。上半段与下半段的执行器分别属于旧进程和新进程;记录写入均假设成功。图只画“结果已保存”的分支,尚未保存的情况在下一节展开。点击图片可放大,也可打开原图或交互查看。

下一次模型请求里,具体放什么

恢复程序已经决定继续,接下来要把什么发给模型?我们把这次请求的输入展开来看。请求要保留原来的消息关系,不能只有一句孤立的“继续”,也不能把原始用户任务当成新任务从头发一遍:

system:
  当前系统指令

user:
  追加“修复登录超时”到 CHANGELOG.md,
  然后运行 pytest -q,最后汇报结果。

assistant / tool_call:
  id: call-9
  name: append_note
  args:
    path: CHANGELOG.md
    line: 修复登录超时

tool / tool_result:
  call_id: call-9
  content: 已追加一行

# 请求还携带当前可用工具的定义,例如运行命令的工具。

这里列的是本例完整的业务消息顺序,省略了真实协议里的时间戳、用量等字段。实际应用还可能有更早的历史、压缩摘要和恢复提示。

这次请求已经包含“追加成功”,因此执行器无须通过再次调用 append_note 来获得结果。这就是复用已有结果:读出先前保存的工具消息,把它放进模型输入。

假设模型接下来返回:

调用 call-10:运行 pytest -q

执行器便回到正常循环:保存 call-10,执行测试,保存测试结果,再请求最终汇报。这个分支正常完成后,记录会继续增长为:

assistant:提出 call-10,运行 pytest -q
tool:call-10 的实际测试结果
assistant:依据实际结果生成的最终汇报
run_finished:run-17,completed

这里没有预先填入“测试通过”。执行器要等工具真正返回,模型才能据此汇报。后续响应也只是教学假设,不保证模型必然选择测试;它仍可能需要澄清,或提出不同的调用。

把以上动作放进代码,控制流大致如下:

# 教学伪代码:单运行、顺序工具;保存成功才进入后续步骤。
# 省略并发接管、分支、取消、预算和存储错误处理。
def recover_on_startup(session):
    for run in session.find_unfinished_runs():
        messages = session.load_messages_for(run)

        for call in find_calls_without_results(messages):
            # 这里才处理结果缺失:重跑,或者补“结果未知”。
            resolve_missing_result(session, run, call)

        messages = session.load_messages_for(run)
        if (not run.cancelled
                and all_calls_have_results(messages)
                and last_message_is_tool_result(messages)
                and not tool_requests_stop(messages)):
            scheduler.enqueue_continuation(run.id)


def drive_continuation(run_id):
    messages = session.load_messages_for(run_id)
    reply = model.generate(messages, tools=current_tools)
    # 保存新回复,执行它提出的新调用,再进入正常循环。
    agent_loop.accept_reply(run_id, reply)

在刚才的快照里,find_calls_without_results 返回空集合。恢复程序不会执行任何旧工具,只安排下一次模型请求。伪代码只覆盖本文的工具结果续接分支,不是任意 Agent 状态的通用恢复算法。

Durable Execution,可以译作持久化执行,补上的就是这套能力:保存恢复所需的进度与意图,在新的进程里读取它们,并按规则推进未完成工作。保存只是前半段;后半段必须真的调用调度器、模型或工具,才构成执行恢复。

如果连工具结果都没有留下,应该怎么接

现在我们回到开头那个更难的窗口,把中断位置往前挪一步:CHANGELOG.md 已经修改,call-9 的结果却没来得及保存。

此时消息只有用户任务和工具调用。恢复程序能识别出 call-9 缺结果,却不能仅凭这个缺口判断文件有没有改过。

把相邻的几个中断位置放在一起,就能看出区别:

中断位置已保存的依据新进程能做出的判断
tool_started 已保存,尚未调用工具调用、参数、策略,没有结果不能只靠这些记录确认外部效果
文件已改,结果尚未保存调用、参数、策略,没有结果与上一行可能看到相同记录,效果仍未知
结果已保存,尚未再次请求模型调用与对应结果,运行尚未结束可以复用结果,安排下一次模型请求

前两行在真实世界里不同,在恢复程序眼里却可能相同。新进程没有看到旧进程的最后一条机器指令,只有已经提交的记录。

为什么保存 tool_started 还不够

我们把“保存执行记录”和“修改文件”排一下顺序,看看为什么多写一条记录仍然不够:

先写 tool_started → 再追加文件

如果进程刚写完记录就退出,文件还没改。如果追加完文件才退出,文件已经改了。两种情况都可能只留下 tool_started。

反过来先改文件、再写记录也不行。进程可能在两步之间退出,留下一个没有记录的文件修改。

问题在于:会话记录的提交与 CHANGELOG.md 的修改,不在同一笔原子事务里。所谓原子,是指几件事要么一起发生,要么都不发生。这里两边各自写入,中间就可能中断。仅在 Session 数据库内部使用事务,也不能自动把外部文件修改包进去。

所以,缺少结果只说明结果没有成功记录,不能证明工具没有生效。 持久化执行必须正面处理这个未知,不能把它当作普通失败再试一次。

不可安全重跑:先把未知告诉下一轮

对于文件追加,恢复程序可以给 call-9 补一条明确的结果:

tool / tool_result:
  call_id: call-9
  is_error: true
  content:
    上次执行在结果保存前中断。
    外部效果未知,可能已追加,也可能没有。
    恢复程序没有自动重跑该调用。

这不是伪造“追加失败”。is_error 表示这次调用没有正常留下可确认的结果,文字内容仍明确区分了失败与未知。

补齐结果有两个作用。它让下一次请求里的工具调用有对应答复,也让模型看见这一步的不确定性,而不是把缺口悄悄当作成功。

后续可以由应用要求模型先读取 CHANGELOG.md,检查目标记录,再决定要不要补做。如果文件可能被其他人修改,或者相同文字可能原本就存在,简单搜到一行也未必足够证明原调用成功。核对规则要由任务本身决定,无法确认时可以转给用户。

拒绝自动重跑旧调用,也不能阻止模型在下一轮重新提出相同写操作。应用仍需约束后续新调用;不能只加一条未知消息,就宣称整个系统不会重复修改。

可以安全重跑:重新执行并保存新结果

如果缺结果的是读取文件,且工具声明允许安全重跑,恢复器可以重新执行读取,把返回内容保存为原调用的结果,然后继续请求模型。

这里确实执行了工具,与“使用已保存结果”不同。而且重新读取的是当前文件,未必等于中断前读到的内容。安全的含义是允许重复这个动作,不是保证返回过去的同一个值。

对发消息、创建订单等外部操作,还可能需要下游提供幂等键,让重复提交被识别为同一次业务操作;或者通过查询接口核对原请求的结果。执行器不能凭自己的一条日志,替另一个系统保证恰好执行一次。

对照 rpi:这些步骤实际落在哪里

前面的记录和伪代码是为了说明机制。带着“结果已保存”和“结果未知”这两条路径,我们再对照 rpi 这个 Rust Agent 项目的实际实现。核查版本为 0.15.0、提交 fd47e29,下面会分别说明教学字段与 rpi 真实记录的对应关系。

运行过程中,先保存哪些恢复依据

rpi 使用 operation_started、operation_finished 表示一次操作的开始与收尾。正常提示路径会把首条用户输入与运行开始记录一起提交,恢复时就有依据查找未结束的操作。

模型提出工具调用后,执行器会尝试提前提交携带调用的 assistant 消息,不等整轮任务结束。准备执行工具时,再保存 tool_started,包含所属运行、调用 ID、实际参数、预留的结果条目 ID,以及当时的重放策略。

工具返回时,执行器会尝试把结果提交到预留条目。这样,若追加结果已经保存、进程却在后续模型请求之前退出,新进程能够读到完整的调用与结果,而不用再追加一次文件。

这些提交边界可以在 settle.rs 的 settle_assistant、start_tool、settle_tool_result 中核对。但“尝试保存”不能读成“无论存储怎样失败,状态都不会丢”。部分写入失败路径只记录告警,并不会一律阻止工具执行。本文的恢复判断以相应记录成功提交为前提,未验证磁盘断电和损坏时的保证。

重启时,谁发起下一次模型请求

宿主在允许恢复已有会话的模式下构造 AgentHarness。create 会查找未结束的操作,修复上次中断留下的状态,再判断当前消息是否适合续接。

对本例“结果已保存”的位置,关键依据是:之前有未结束的运行,当前分支已经到工具结果,而且这个结果没有要求终止运行。符合条件时,Harness 登记一个 PendingResume,也就是待续接任务。

登记本身还没有请求模型。宿主调用 resume_pending 后,Harness 才实际进入续接路径。rpi 的交互启动代码与其他运行模式中都有这个调用入口,不需要用户重新输入原任务。

续接也有保护条件:次数预算用尽,或消息处在不允许继续的位置时,不会直接启动。已经正常结束或明确取消的运行,没有那条未结束操作,也不该因为打开历史而重新执行。

rpi 还有一个容易被“恢复原任务”掩盖的实现细节:它会先把旧 operation 以 Aborted / interrupted 收尾,再为续接建立新的 operation。新记录通过 resumedFrom、resumeAttempt 保留关联及续接次数。

从用户角度看,任务接着做了;从运行记录看,这是新的执行过程接手旧上下文。原进程的 Rust 调用栈、旧 HTTP 连接和内存变量都没有被复活。

结果缺失时,按什么规则处理

rpi 的 resolve_tool_call 负责处理缺少结果的调用。对普通本地工具调用,它要求两项条件同时成立,才会自动重跑:调用当时保存的策略是 Safe,当前注册的工具仍声明为 Safe。

历史策略说明当时是否允许重跑,当前声明说明现在是否仍允许;缺少其中一项,就不能仅凭工具名字猜测。

CLI 默认工具中,read、docs 显式声明为 Safe。bash、write、edit 默认是 Never。因此,即使某一次 bash 看起来只是读文件,它也不会自动获得安全重跑资格;执行器按注册策略处理,不靠分析 shell 字符串来证明安全。

用 bash 追加记录后中断,若结果缺失,恢复器不会再次执行那条旧命令。它会补上说明外部效果未知的工具结果,让后续模型请求有明确的事实边界。即便调用消息已经保存、进程还没来得及写 tool_started 就退出,也不能据此自动断言“工具一定没有执行”。

Safe 声明由工具注册者承担。它是一项安全承诺,不是 Harness 自动证明了工具实现没有副作用。

本次复核还运行了 rpi 的无新提示续接测试。它使用模拟 Provider,并通过中止任务制造中断;没有用真实模型跑完本文的三步任务,也不是断电实验。本地另有教学脚本用子进程退出验证上表三个窗口,两者的实现和验证范围不同。

除了当前工具,还有哪些工作不能只留在内存里

文件追加只是一个最容易看见的中断点。同一原则也适用于排队输入和等待中的重试。

例如,Agent 正在运行,用户又发来一句“测试结束后也检查格式”。应用如果已经确认接收,却只把指令放在内存队列里,进程退出后这项工作就消失了。聊天界面上能看见一句话,也不等于执行器记住了它尚未处理。

rpi 在把输入发布到内存队列之前,先保存入队记录。恢复时据此重建仍待处理的输入,而不是把会话里每句用户消息重新执行一遍。

重试也类似。请求失败后,如果决定稍后重试,内存里的计时器会随进程消失。rpi 在进入等待前保存 retry_pending 意图,让恢复代码能识别“这次工作还在等待重试”,而不是只剩一条失败消息。

这些记录首先是给执行器安排工作用的,不必全部变成模型聊天内容。保存对话和保存待办可以共用 Session 存储,但由不同代码读取,并产生不同动作。

判断自己的实现,缺的是哪一步

如果只需要打开历史、由用户决定何时继续,消息持久化可以满足这个产品目标。如果系统承诺重启后接手已接受的任务,我们可以沿着一次工具调用,在下面三个位置检查自己的实现:

  • 结果已保存、下一次模型请求未发出时退出:谁识别未结束运行,谁实际发起新请求?
  • 外部操作可能完成、结果尚未保存时退出:依据什么允许重跑,不能重跑时怎样暴露未知?
  • 运行已正常结束或用户已取消后重启:依据什么阻止它被再次当成待办?

这三个问题不要求买一个名字叫 Durable 的产品,也不要求把所有状态搬出 Session。它们要求应用写出明确的记录与恢复规则。

回到追加记录这件事:如果结果已经保存,就把它交给下一次模型请求;如果结果没有保存,就先处理这一步的不确定性。能在新进程里完成这些动作,才从“历史还在”走到了“工作有人接手”。


云服务器推荐

如果你需要部署博客、Docker 服务或个人项目,可以参考:

本文包含推广链接。通过链接购买不会增加你的费用,但我可能获得少量佣金。

评论