第 19 章 会话日志与恢复

本章导读:从"后台聊天"场景出发讲 AgentSession 与 Session Log——提交任务与持久排队、运行中补充要求(steer / inject)、页面刷新后的快照恢复、回复 HITL 待办、中断续做与服务重启后的核对,再到事件结构与日志读取、存储后端配置、导出与自定义事件。读完本章,你可以构建"页面关了任务照跑、断线之后原样恢复"的会话型应用。

flowchart LR A["你的需求
用户等待期间关了页面
任务还得继续跑"] --> B["会话日志与恢复
AgentSession 管操作 · Session Log 管保存"] B --> C["提交任务
后台执行 · 持久排队"] B --> D["运行中引导
补充要求 · 回复 Agent 的提问与确认"] B --> E["刷新与中断
快照恢复 · 从保存的状态续做"] B --> F["事件与存储
事件结构读取 · 存储后端配置"] C --> G["收益
页面关了任务照跑
断线之后原样恢复"] D --> G E --> G F --> G

本章导览图

用户让 Agent 比较两份方案,等待期间又补充了一条要求,随后关闭页面。再次打开时,应用应该展示之前的回答、工具执行结果和当前进度;如果任务曾被中断,还应该能从保存的状态继续。

这涉及两件事:谁负责把任务继续执行下去,以及执行过程保存在哪里。AgentSession 提供提交、引导、回复和恢复等会话操作;Session Log 保存输入、消息、工具执行和工作状态。前端读取这些记录,就能在重新连接后恢复页面,而不必重新发起任务。

本章从这个聊天场景逐步介绍用法,再说明事件查询、存储配置和扩展方式。可以配合第 40 章(最佳实践:可恢复会话聊天)实际体验。通过 AgentScope Service 使用托管 Agent 的应用,直接接入 Service Agent API 的 HTTP/SSE 接口即可(见第 31 章)。

先选择调用方式

普通问答、多轮聊天,以及随当前请求结束的流式回复,继续使用 agent.call(input, ctx) 或 agent.streamEvents(input, ctx)。HarnessAgent 默认也会保存这些调用的消息历史和 checkpoint;仅为了记住上一轮对话,不需要改用 AgentSession。这类请求推荐从共享 Builder 创建新 Agent,执行结束后关闭,见第 2 章(快速开始)。

当任务需要在页面关闭后继续,或需要排队、运行中补充要求、回复待办和中断后续做时,再使用 agent.session(ctx)。返回的 AgentSession 是这段会话的操作入口,由框架持有后台执行;HTTP 请求或前端观察连接结束,不会因此取消任务。

后台执行需要对应的生命周期:应用的会话管理器或任务 worker 可以从共享 Builder 构建 Agent,但应持有它直到后台工作结束或应用退出,不能在提交接口返回时就关闭。具体资源管理方式见第 4 章"实例生命周期"一节。

提交任务,让它在后台执行

假设要做一个"比较方案"的聊天助手。应用启动时配置共享 Builder,由会话管理器构建并持有运行实例,再用用户和会话身份取得 AgentSession。下面的 model 是已经配置好的模型(见第 6 章):

import io.agentscope.core.agent.RuntimeContext;
import io.agentscope.harness.agent.HarnessAgent;
import java.nio.file.Path;

HarnessAgent.Builder agentBuilder = HarnessAgent.builder()
        .name("方案助手")
        .agentId("proposal-assistant")
        .model(model)
        .workspace(Path.of("/data/proposal-workspace"));

// 由应用的会话管理器持有,后台任务结束或应用退出时再关闭。
HarnessAgent agent = agentBuilder.build();
RuntimeContext ctx = RuntimeContext.builder()
        .userId("alice").sessionId("conversation-001").build();
var session = agent.session(ctx);

var task = session.submit("request-001", "比较方案 A 和方案 B,先给出初步结论。");
System.out.println(task.turnId());

submit 会先持久保存输入,再安排执行。返回的 task 是接收回执,不是模型回复;任务还在排队时,runId 可能尚未分配。Web 接口通常把回执返回前端,再让前端独立读取进度。

这里的 request-001 应是业务为这次提交生成的唯一请求标识。网络超时后重试时,沿用相同标识和输入,就会得到同一次提交的回执;用户主动发起另一个任务时,应使用新标识,也可以调用不带标识的 submit(input),由框架生成。

如果用户在比较尚未结束时又提交"根据结论起草一封邮件",这就是另一项任务,会按接收顺序排队。当前任务等待人工答复或被中断时,队列仍会保留,先处理原任务的待办或继续原任务,再执行后续任务。session.tasks() 可以读取任务状态。

在命令行或后台代码中,可以等待这次执行结束,然后读取已保存的消息:

var outcome = session.await(task).block();
System.out.println(outcome.status());
session.transcript().messages()
        .forEach(message -> System.out.println(message.getTextContent()));

await 只负责观察。它会在本次执行完成、挂起、中断或失败后返回;suspended 表示 Agent 正在等用户答复,不代表整个任务完成。取消对 await 的订阅也不会取消后台任务。

即使此前一直通过 agent.call(input, ctx) 对话,仍可以用相同身份取得 session 并读取 transcript(),不必再次提交输入。同一会话选择一套执行方式:直接调用,或由 AgentSession 调度。 已提交任务的进度通过日志观察;再调用 streamEvents 会启动另一次执行。

在执行过程中补充要求或材料

比较正在进行时,用户说"重点看运维成本"。这句话是在调整当前任务,而不是要求再做一次比较。处理这条用户消息的请求可以调用 steer:

session.steer(task, "重点比较运维成本,而不是只看采购价格。");

框架会把这条要求关联到原任务,在下一次推理步骤交给模型。已经发出的模型请求和正在执行的工具参数不会被改写。传入 task 还能检查目标是否仍是当前任务,避免用户在旧页面发出的要求误落到后来的任务上。应在任务仍运行时调用,而不是等前面的 await 返回后再调用。

如果补充的是背景资料,例如"团队只有两名运维人员",可以使用 inject:

session.inject("补充材料:团队只有两名运维人员,没有夜间值班安排。");

inject 保存材料供后续推理使用,但不会单独启动任务。Agent 正在工作且还有后续步骤时,可以在当前执行中读取;会话空闲或当前执行已没有后续步骤时,材料留到后续执行。未被消费的 steer 则仍属于原任务,中断后继续该任务时再读取。

因此,业务界面可以把"发送新任务"和"补充当前任务"做成不同操作:新任务调用 submit,调整当前工作调用 steer,只存入参考资料调用 inject。用户不需要理解底层 turn 或 run,也不需要手工设置这些标识。

刷新页面后,先恢复内容,再继续接收事件

页面重连与任务执行是两条独立链路。用户刷新页面时,后台可能已经完成了一次工具查询,写出半段比较结论,又开始执行第二个工具。此时仅接收"从现在开始"的新片段,页面就会缺少之前的内容。

先区分两种读取需求。只展示已经保存的完整消息时,使用 session.transcript() 即可:它返回消息列表和这份历史对应的 asOfSeq。但它不是一个完整的流式聊天界面快照,不包含从 model/chunk 重建的进行中文本或工具参数。

如果要恢复"写到一半的回复"和工具卡片,应用需要把已提交事件整理成页面视图。第 40 章的聊天示例中的 ChatHistory 和 ChatItems 已实现这部分:除了完整历史,还恢复文本前缀、ToolCall 参数、ToolResult、执行进度和待答请求。这是示例提供的应用层视图,可以作为你接入前端时的参考。

接入时,围绕同一份快照完成以下流程:

  1. 打开或刷新页面,先取快照。 后端选定一个已提交日志位置,读取截至该位置的记录,生成页面内容,并一起返回 cursor。例如 cursor 对应第 120 条事件,快照就应该包含截至第 120 条的消息和工具状态。
  2. 渲染完成后,从这个 cursor 继续观察。 即使获取快照期间后台已产生第 121~125 条事件,续读也会把它们补上。消息和工具应按稳定 ID 更新原条目,避免同一个结果重复显示。
  3. 区分短暂断线与页面刷新。 页面仍保留内容时,可以从最后已处理的事件位置续读;刷新导致页面状态丢失时,应重新获取完整快照。只在浏览器保存一个 cursor,并不能恢复 cursor 之前的内容。

聊天示例把快照暴露为 GET /api/sessions/{id},再通过 GET /api/sessions/{id}/stream?after={cursor} 提供 SSE。它收到 committed 通知后会合并频繁通知并重新读取快照,让页面始终显示后端整理好的视图;并不是把每条原生事件都直接交给浏览器拼接。这些 HTTP 接口属于示例应用,SDK 本身提供的是会话与日志读取 API。

重新连接时只做读取,不要再次调用 submit 或 streamEvents。浏览器的连接负责观察已有任务,任务是否继续由会话管理器负责。使用 Service 时,可以直接采用其公共快照、SSE 和 cursor 协议,见第 31 章(Session API)。

回复 Agent 的问题或确认请求

假设 Agent 在生成建议前需要询问部署环境,或在执行工具前需要用户确认。通过 AgentSession 提交的任务可以挂起等待回答;问题保存在日志里,用户刷新页面、甚至服务重启后仍能看到。

用 session.pending() 读取尚未答复的交互。返回值的 key 是 requestId,页面应把它与对应的问题一起保存,提交答案时原样带回:

session.pending().forEach((requestId, request) ->
        System.out.println(requestId + " " + request.data().get("kind")));

对于 ask_user 或外部执行产生的 external_execution 请求,把用户答案或真实执行结果交给 respond。下面的标识需要替换成页面所回答的待办 ID:

var answered = session.respond("external-request-id", "部署在自建 Kubernetes 集群。");

如果待办类型是 confirmation,用布尔值表示同意或拒绝,例如 session.respond(requestId, true)。需要附带拒绝原因时,可传 SessionAnswer.reject(reason)。框架会查找待办所属的原任务和工具调用,应用无需自己拼接工具 ID,也无需再调用 resume;respond 已经安排了接续执行。

respond 返回新的执行回执。需要等待这次接续时,使用 session.await(answered);原提交的 await(task) 观察的是挂起前的那次执行。未知、已处理或答案类型不匹配的待办会被拒绝。

需要自定义工具结果块时使用 SessionAnswer.Output;需要提交修改后的参数或权限规则时使用 SessionAnswer.Confirmation。同一任务内多个并行工具的答案可以用 respond(Map<String, SessionAnswer>) 一起提交。Service 的在线交互通过其所属服务的 actions 接口答复。

这是对原有 HITL 机制的会话封装:Agent 仍在原有的工具确认或外部输入位置暂停,Session Log 让待办与后续答案可持久恢复。直接使用 call 的应用也可以继续使用原有 HITL API(见第 4 章),不必仅为人工确认更换执行方式。

中断后继续原任务

用户点击"停止"时,调用 session.interrupt() 请求当前执行在协作检查点停止。它不是立即终止工具的命令,方法返回也不代表执行已经停下;应等待任务状态变成 interrupted,再提供"继续"操作。

下面展示同一任务的停止与接续。Web 应用通常把这两步放在两个用户操作中;这里用 await 表示等待停止完成:

session.interrupt();
var stopped = session.await(task).block();
if ("interrupted".equals(stopped.status())) {
    var continued = session.resume(stopped.turnId());
    System.out.println(continued.turnId());
}

前端已经知道正在显示的 runId 时,可以使用 session.interrupt(runId),避免旧页面的停止操作误中断另一轮执行。resume 会恢复已保存的工作状态与尚未应用的原输入,继续原任务;业务无需重发用户消息、构造空输入或设置 turnId。

恢复使用的是 checkpoint:执行过程中保存的会话工作状态。它让 Agent 能从已提交状态接着推理,但不会恢复原 Java 线程、网络请求或工具内部执行进度,也不会回滚工具已经产生的外部效果。普通工具所需的凭据、工作文件和外部资源也必须仍然可用。

当前只能继续最新的未完成任务。已经完成或取消的任务不能 resume;有待答交互时应先 respond。如果之前通过 agent.call 或 agent.streamEvents 直接执行,中断仍使用运行实例上的 agent.interrupt;日志负责保存状态,会话入口负责安排后续任务,二者不是互相替代的机制。

服务重启后继续

重启后用相同的 agentId、userId、sessionId 和日志后端重新构建 Agent,再取得 session。此时可以直接读取历史和待办;读取本身不会触发模型调用。

session.transcript().messages()
        .forEach(message -> System.out.println(message.getTextContent()));
session.start(); // 应用启动时,重新开启已接收任务的调度。

start() 会检查持久队列,但不会擅自恢复已中断的任务;由应用决定何时调用 resume,或等待用户回答待办。submit、resume、respond 本身会自动开启调度,因此正常交互不必每次手工调用 start()。

关闭所属 agent 会停止调度并中断其持有的执行,已提交日志和队列由持久后端保留。如果进程被强制终止,可能还需要等待旧执行的写入租约释放或到期,新的执行才能安全接手。

核对结果未知的工具

假设工具已经在订单系统创建了订单,但进程在记录工具结果之前退出。日志能说明工具曾被调用,却不能证明订单创建成功还是失败;直接重试可能重复创建订单。

这种情况下,先通过 session.inspect().uncertainToolCalls() 找出需要核对的工具调用,再查询真实业务系统。确认结果后,把对应的工具调用 ID、工具名称和实际结果交回框架:

import io.agentscope.core.message.TextBlock;
import io.agentscope.core.message.ToolResultBlock;
import io.agentscope.core.message.ToolResultState;
import java.util.Map;

System.out.println(session.inspect().uncertainToolCalls());

// ID 与名称来自待核对调用,结果来自对订单系统的实际查询。
var result = ToolResultBlock.builder()
        .id("call-id-from-inspection").name("create_order")
        .output(TextBlock.builder().text("已核实订单 ORD-42 创建成功").build())
        .state(ToolResultState.SUCCESS).build();
agent.reconcileToolOutcomes(ctx, Map.of(result.getId(), result), "已核对订单系统记录");

结果集合需要恰好覆盖检查中列出的未知调用。这个操作只保存核对结果和 checkpoint,不再次调用工具;之后再显式 resume 原任务。实际结果仍未知时,不应填一个空的成功结果来绕过检查。

session、turn 和 run 如何对应

可以把 session 理解为一段持续对话,turn 理解为用户提交的一项任务,run 理解为完成该任务的一次实际执行。一次 run 中可以多次推理、调用多个工具,并生成多条消息;它不等于一次模型调用或一条 AssistantMessage。

例如,用户在会话 S1 中要求"比较方案,发邮件前先让我确认":

用户操作或执行状态 turnId runId
提交比较任务,Agent 开始推理并查询资料 T1 R1
刷新页面,恢复消息和工具卡片 T1 仍是 R1
补充"重点比较成本"(steer) T1 仍是 R1
中断任务,之后点击继续 T1 R1 结束,新建 R2
Agent 挂起等待确认,用户答复后继续 T1 R2 结束,新建 R3
完成后提交另一项任务 新建 T2 新建 R4

表中的 sessionId 始终是 S1。这里展示的是挂起式 HITL;如果采用执行仍保持存活的在线等待方式,收到答复后也可以继续原 run。业务通过 submit、steer、respond 和 resume 表达意图,框架负责关联这些身份。

日志还使用稳定的 agentId 和 userId 区分 Agent 与用户。不要把提交请求的幂等 key、HITL 的 requestId、工具调用 ID 或事件 eventId 混作 turnId:它们分别用来防止重复提交、回答待办、关联工具结果和去重事件。

事件结构与读取

前面的页面恢复、待办展示和中断续做,都依赖同一份 Session Log。它保存"执行中发生了什么",再从记录中生成不同视图:transcript() 用于消息历史,pending() 用于待办,inspect() 用于恢复检查。checkpoint 保存继续执行所需的工作状态;上下文压缩后,界面历史仍可保留更早的消息,模型后续使用的工作上下文则可能已经缩短。

AgentEvent 是 streamEvents 产生的实时通知,适合展示当前请求;SessionEvent 是已持久保存的记录,适合历史读取、恢复和导出。实时通知到达不代表对应日志已经提交,需要可靠重放时应以 Session Log 的已提交内容为准。Service 的公共 SSE 还会对事件做面向客户端的整理,它的 cursor 与 SDK 原生序号不是同一个协议。

排查某次任务时,可以按 turnId 查看它跨多次执行的记录,再按 executionRunId 细看一次执行。下面从当前日志末尾确定读取范围,不会触发模型或工具:

var log = session.log(); // 也可以使用 agent.sessionLog(ctx)。
long through = log.head().seq();
for (var event : log.scan(0, through)) {
    System.out.printf("%d %s turn=%s run=%s%n",
            event.seq(), event.type(), event.turnId(), event.executionRunId());
}

seq 是当前会话内从 1 开始递增的持久序号,排序和续读都使用它。scan(0, through) 读取截至 through 的固定范围;需要分批续读时,使用 readAfter(lastSeq, limit),处理完成后再保存最后一条事件的 seq。eventId 用于接收端去重,不能当作顺序号。

这些日志读写和 AgentSession 的同步操作会访问存储。在 WebFlux handler 中,应使用 Mono.fromCallable(...).subscribeOn(Schedulers.boundedElastic()) 调度,避免阻塞网络事件线程;await 自身已安排好状态轮询。

事件目录

普通聊天应用不需要逐项处理下面所有事件。需要构建时间线、查询工具结果或做诊断时,再按关注的内容读取对应类型。

关注什么 事件类型
执行开始、结束与停止请求 run/start、run/end、run/stop_requested
任务开始、接续与输出 turn/start、turn/resumed、turn/output
任务完成、挂起、失败、中断或取消 turn/completed、turn/suspended、turn/failed、turn/interrupted、turn/cancelled
推理步骤 step/start、step/end
输入接收、应用或丢弃 input/received、input/applied、input/discarded
系统、用户和 Agent 消息 message/system、message/user、message/assistant
模型请求、返回片段、用量与重试 request/prepared、model/dispatch、model/chunk、model/end、model/retry
工具请求、权限判断、参数、进度与结果 tool/requested、tool/decision、tool/dispatch、tool/chunk、tool/result
工具内具体动作的边界 action/start、action/end
人工待办及其答复 interaction/requested、interaction/resolved
上下文构建、替换与压缩 context/build、context/replaced、compaction/start、compaction/end
任务、计划、权限与验证状态 task/changed、plan/changed、permission/changed、verification/result
子 Agent 与父会话的关联 subagent/spawned、subagent/completed、subagent/linked;完整子 Agent 过程在其自己的日志中
状态快照、恢复核对与导入 state/checkpoint、state/restored、recovery/applied、migration/baseline
展示提示与应用扩展 presentation/hint、应用自己定义的类型

记录范围是模型适配器可见的请求与输出、工具和框架执行过程,不包括模型服务内部过程,也不会自动备份被引用的文件。原始请求和工具结果可能包含业务数据,面向浏览器的接口应挑选需要展示的字段。

compaction/end 的载荷包含 compactionId、status、beforeMsgCount 和 beforeTokenCount。当 status 为 completed 时,还会包含根据压缩后有效对话计算的 afterMsgCount 和 afterTokenCount。压缩失败或取消时不会伪造压缩后指标,因为此时没有可用的压缩后上下文。

集成时需要的 SessionEvent 字段

每条 SessionEvent 的载荷都会冻结为 JSON,后续修改原对象不会改变已记录内容。

字段 用途
schemaVersion 事件结构版本
eventId 单条事件的稳定 ID,用于去重
seq 会话内的持久顺序
occurredAt 事件时间;排序仍使用 seq
type 选择解析和处理方式
turnId / executionRunId 关联逻辑任务与实际执行;部分管理操作没有 turnId
payloadJson / data() JSON 载荷及解析后的 Map
required 恢复时是否必须识别该事件类型

存储位置与后端配置

默认情况下,HarnessAgent 使用 WorkspaceSessionLogStore,日志跟随 Workspace 的 Filesystem 保存。Workspace 可以使用本地磁盘,也可以使用分布式存储。 创建新 Agent 实例或重启应用后,只要身份与存储配置保持一致,就能找到原会话。

配置 去哪里查找记录
本地 Filesystem 当前身份解析后的 Filesystem 根目录下 .agentscope-runtime/
RemoteFilesystem 对应 BaseStore namespace 下的 __agentscope_session_log_v1__ 分区
单独配置日志后端 .sessionLogStore(store) 指定的后端
AgentScope Service 托管 由服务配置原生日志和公共事件存储,见第 31 章(Session API)

Linux 和 macOS 的本地存储会先同步临时文件,再通过原子替换提交记录,并同步父目录。Windows 的 Java 文件系统不能打开目录来执行这种同步,因此本地 Session 存储改用 .agentscope-runtime/journal.sqlite3 中的 SQLite 事务,启用回滚日志和 synchronous=EXTRA。写入仍需通过原子的版本比较,存储错误也会返回给调用方。应用继续通过 SDK 读取记录;备份 Windows 工作区时,应先暂停所有写入再复制数据库,或者使用 SQLite 的一致性备份。POSIX 文件和 Windows 数据库存放的是同一套逻辑对象,但物理格式不同,跨平台迁移历史时需要进行日志迁移,或者使用共享后端。

这里的"当前身份"由 SessionKey(userId, agentId, sessionId) 和 Filesystem 的 namespace 共同确定。开启用户或会话隔离后,实际根目录会随身份变化,因此不能把所有会话都理解为写入 Workspace 下同一个固定目录。多副本必须连接同一份日志存储,并使用一致的身份与 namespace;仅让每个副本使用同名的本地目录并不能共享会话。

将日志存到共享后端

如果工作文件放在本机或沙箱,但希望会话日志跨节点保存,可以单独配置日志后端。下面的 sharedStore 是应用已经配置好、支持原子版本写的 BaseStore:

import io.agentscope.harness.agent.filesystem.remote.RemoteFilesystem;
import io.agentscope.harness.agent.session.WorkspaceSessionLogStore;
import java.util.List;

var logStore = new WorkspaceSessionLogStore(
        new RemoteFilesystem(sharedStore, List.of("my-app", "session-history")));
HarnessAgent.Builder sharedLogBuilder = HarnessAgent.builder()
        .name("方案助手").agentId("proposal-assistant")
        .model(model).workspace(Path.of("/data/proposal-workspace"))
        .sessionLogStore(logStore);

随后从这个 Builder 构建 Agent 即可,调用方式不变。可选后端及连接方式见第 24 章(会话存储与分布式部署)。内存后端适合测试,进程退出后不会保留历史。

共享存储不会让直接调用的同会话请求自动排队,这类请求仍需应用协调。使用 AgentSession 时,任务通过持久输入队列接收和排队,并由已启动的会话调度器执行。会话列表也受当前 namespace 范围限制,SESSION 隔离下的列表不能枚举其他会话。

自定义后端与备份

一般应用只需选择现成后端。需要接入自己的存储时,可以实现 SessionLogStore,或实现 AtomicSessionStorage 并复用 JournalSessionLog。它们需要支持原子版本比较写入、执行写入租约与旧写入者隔离、有序幂等提交及导出进度持久化;普通文件的读写接口不足以保证这些语义。

自定义 Filesystem 应提供 sessionStorage 能力,或者单独指定日志后端。使用 AgentSession 命令还要求 SessionLog.inbox() 支持持久输入队列;默认 Workspace 和内存日志已提供。需要列出会话时,使用 SessionLogStore.list(RuntimeContext)。

备份建议使用后端一致性快照,或先暂停写入,再保存会话记录。工作文件、沙箱内容、外部产物和工具依赖需要另外备份,只有日志不足以完整恢复所有外部环境。

需要运维或实现后端时:日志对象与输入队列

每个会话在日志根目录或分区下保存如下对象,身份段使用 Base64URL 编码:

agents/s_<agent>/sessions/s_<user>/s_<session>/
  session.json
  head.json
  commits/<batch-hash>.json
  blobs/<sha256>
  exports/<sink-hash>.json
  inbox/
    session.json
    head.json
    commits/<batch-hash>.json

commits 保存提交批次,blobs 保存较大的载荷,exports 保存各导出目标的确认位置。备份需覆盖会话头、日志头及其可达的 commits、blobs 和导出记录,也要包含 inbox/ 下的日志头及其可达对象,否则尚未执行的已接收输入可能丢失。这些对象并非供应用逐行解析的 JSONL;POSIX 本地对象带有版本前缀,Windows 则把逻辑路径、版本和载荷保存为 SQLite 数据行,应通过 SessionLog API 读取。

inbox 保存任务、引导、材料及回复的接收记录,使输入在执行前也可以持久保留。它有独立的序号,不与执行日志的 seq 或 SSE cursor 混用。输入在 checkpoint 提交后才标记为已应用,消费也不会删除原接收记录。

排查接收与执行衔接时,输入队列中的 inbox/accepted、inbox/opened、inbox/closed、inbox/handled、inbox/rejected 记录接收和处理状态;执行日志中的 inbox/started、inbox/applied 关联实际运行与输入应用。

同一 Agent 会复用相同身份的 AgentSession,并使用首次创建时的 RuntimeContext 副本调度。用户身份、namespace 和会话级配置应保持稳定;之后每条消息通过会话操作传入。

导出到其他系统

如果要把执行历史送到审计系统或数据仓库,使用 SessionLogExporter 读取已提交事件,并交给应用实现的 SessionExportSink。导出只读取历史,不会重新运行 Agent。

import io.agentscope.core.session.SessionLogExporter;

// sink 是应用实现的 SessionExportSink,负责持久写入目标系统。
var exporter = new SessionLogExporter(session.log(), sink);
exporter.drain();

sink.accept 返回应表示目标已经持久接收。导出失败后可能重复投递同一事件,目标端需按 eventId 去重;sink.name() 应跨部署保持稳定,框架用它保存各目标的导出进度。应用可以在启动、空闲时或定时任务中再次 drain() 补投,并协调多副本对同一会话和目标的导出。

需要执行时自动触发导出时,先将 sink 放入 RuntimeContext 的 SessionExportSink.CONTEXT_KEY,再首次调用当前 Agent 实例的 agent.session(ctx)。这不能替代失败后的补投安排。Service 已提供公共事件导出与 SSE,使用托管接口的业务通常不必自行实现这一层。

记录应用自己的事件

例如,人工复核或业务校验发生在工具或中间件中,希望它也出现在执行时间线上,可以从本次运行的 RuntimeContext 取得 SessionRecorder,写入带应用前缀的事件类型:

import io.agentscope.core.agent.RuntimeContext;
import io.agentscope.core.session.SessionRecorder;
import java.util.Map;
import reactor.core.publisher.Mono;

static Mono<Void> recordReview(RuntimeContext context, String note) {
    SessionRecorder recorder = SessionRecorder.from(context);
    if (recorder == null) return Mono.empty();
    recorder.append("acme/review_note", Map.of("note", note), false);
    return recorder.flush();
}

将这个方法返回的 Mono 组合进工具或中间件的调用链,才能等待事件提交。这里的 false 表示该事件只提供诊断信息,恢复状态时不要求识别它。不要在调用结束后继续保留 recorder 写入。

如果自定义事件还要参与状态恢复,需要在运行和恢复前通过 SessionEventCodecRegistry 注册校验器与状态还原逻辑。标记为 required 却无法识别的事件会阻止恢复,因此普通业务诊断事件通常使用 required=false。

迁移与分支会话

已有应用若保存的是 AgentState,可以把它作为一份起始状态导入新的空会话。需要从当前对话尝试另一条路线时,则可以建立新的分支会话,保留源会话不变。

需求 API
将完整状态导入空目标,并记录来源 SessionMigration.importBaseline(target, state, source, sourceVersion)
导出状态及其日志位置 asOfSeq SessionMigration.exportSnapshot(source)
只导出工作状态 SessionMigration.exportState(source)
用新身份从当前状态建立会话 SessionMigration.fork(source, destination, destinationKey, sourceReference)

导出或分支前应停止源执行,并处理未知工具结果、尚未结束的执行和待答交互。导入保留的是当时的状态,不会补造更早的执行历史;分支也不会自动复制原会话的完整历史、沙箱或外部资源。

把这些能力接到完整应用中

第 40 章(最佳实践:可恢复会话聊天)的可恢复聊天示例可以离线运行,也可以接入真实模型。建议先启动它,发送多步任务,在工具执行和文本生成期间刷新页面;再试一次待办答复、中断与继续。这样可以直接看到:提交接口负责表达用户意图,后台会话负责执行,快照与事件负责恢复页面,checkpoint 负责继续任务。

本章小结

围绕开头那个比较方案的聊天场景,这一章的机制可以按一条路线串起来:

  1. 选入口:任务需要在页面关闭后继续时才用 agent.session(ctx);普通调用保持 call / streamEvents,后台执行要求应用持有实例直到工作结束;
  2. 提交与排队:submit 先持久保存输入再安排执行,唯一请求标识让超时重试安全,后续任务按接收顺序排队;
  3. 中途介入:调整当前任务是 steer,只存参考材料是 inject,两者都不打断正在进行的执行;
  4. 恢复页面:先按已提交日志位置取快照并拿到 cursor,再从 cursor 续读事件,短暂断线与页面刷新分别处理;
  5. 待办与中断:pending() 读取、respond() 答复并自动安排接续,interrupt() 加 resume() 从 checkpoint 继续原任务;结果未知的工具调用先核对再 resume;
  6. 排查与观测:session / turn / run 三层身份各管一段,按 seq 排序续读事件,eventId 只做去重;
  7. 存储与扩展:日志默认跟随 Workspace Filesystem,跨节点换共享后端,历史可导出到审计系统,应用事件可写进执行时间线,旧状态可迁移、会话可分支。

这些机制的完整落地样例就是第 40 章的可恢复聊天示例;什么时候该选哪个操作的决策依据,第 41 章逐条展开。

验收清单——读到这里,你应该能: