Skip to main content
用户让 Agent 比较两份方案,等待期间又补充了一条要求,随后关闭页面。再次打开时,应用应该展示之前的回答、工具执行结果和当前进度;如果任务曾被中断,还应该能从保存的状态继续。 这涉及两件事:谁负责把任务继续执行下去,以及执行过程保存在哪里。AgentSession 提供提交、引导、回复和恢复等会话操作;Session Log 保存输入、消息、工具执行和工作状态。前端读取这些记录,就能在重新连接后恢复页面,而不必重新发起任务。 本文从这个聊天场景逐步介绍用法,再说明事件查询、存储配置和扩展方式。可以配合可恢复聊天示例实际体验。通过 AgentScope Service 使用托管 Agent 的应用,直接接入 Service Agent API 的 HTTP/SSE 接口即可。

先选择调用方式

普通问答、多轮聊天,以及随当前请求结束的流式回复,继续使用 agent.call(input, ctx) 或 agent.streamEvents(input, ctx)。HarnessAgent 默认也会保存这些调用的消息历史和 checkpoint;仅为了记住上一轮对话,不需要改用 AgentSession。这类请求推荐从共享 Builder 创建新 Agent,执行结束后关闭,见快速开始。 当任务需要在页面关闭后继续,或需要排队、运行中补充要求、回复待办和中断后续做时,再使用 agent.session(ctx)。返回的 AgentSession 是这段会话的操作入口,由框架持有后台执行;HTTP 请求或前端观察连接结束,不会因此取消任务。 后台执行需要对应的生命周期:应用的会话管理器或任务 worker 可以从共享 Builder 构建 Agent,但应持有它直到后台工作结束或应用退出,不能在提交接口返回时就关闭。具体资源管理方式见实例生命周期。

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

假设要做一个“比较方案”的聊天助手。应用启动时配置共享 Builder,由会话管理器构建并持有运行实例,再用用户和会话身份取得 AgentSession。下面的 model 是已经配置好的模型:
submit 会先持久保存输入,再安排执行。返回的 task 是接收回执,不是模型回复;任务还在排队时,runId 可能尚未分配。Web 接口通常把回执返回前端,再让前端独立读取进度。 这里的 request-001 应是业务为这次提交生成的唯一请求标识。网络超时后重试时,沿用相同标识和输入,就会得到同一次提交的回执;用户主动发起另一个任务时,应使用新标识,也可以调用不带标识的 submit(input),由框架生成。 如果用户在比较尚未结束时又提交“根据结论起草一封邮件”,这就是另一项任务,会按接收顺序排队。当前任务等待人工答复或被中断时,队列仍会保留,先处理原任务的待办或继续原任务,再执行后续任务。session.tasks() 可以读取任务状态。 在命令行或后台代码中,可以等待这次执行结束,然后读取已保存的消息:
await 只负责观察。它会在本次执行完成、挂起、中断或失败后返回;suspended 表示 Agent 正在等用户答复,不代表整个任务完成。取消对 await 的订阅也不会取消后台任务。 即使此前一直通过 agent.call(input, ctx) 对话,仍可以用相同身份取得 session 并读取 transcript(),不必再次提交输入。同一会话选择一套执行方式:直接调用,或由 AgentSession 调度。 已提交任务的进度通过日志观察;再调用 streamEvents 会启动另一次执行。

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

比较正在进行时,用户说“重点看运维成本”。这句话是在调整当前任务,而不是要求再做一次比较。处理这条用户消息的请求可以调用 steer:
框架会把这条要求关联到原任务,在下一次推理步骤交给模型。已经发出的模型请求和正在执行的工具参数不会被改写。传入 task 还能检查目标是否仍是当前任务,避免用户在旧页面发出的要求误落到后来的任务上。应在任务仍运行时调用,而不是等前面的 await 返回后再调用。 如果补充的是背景资料,例如“团队只有两名运维人员”,可以使用 inject:
inject 保存材料供后续推理使用,但不会单独启动任务。Agent 正在工作且还有后续步骤时,可以在当前执行中读取;会话空闲或当前执行已没有后续步骤时,材料留到后续执行。未被消费的 steer 则仍属于原任务,中断后继续该任务时再读取。 因此,业务界面可以把“发送新任务”和“补充当前任务”做成不同操作:新任务调用 submit,调整当前工作调用 steer,只存入参考资料调用 inject。用户不需要理解底层 turn 或 run,也不需要手工设置这些标识。

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

页面重连与任务执行是两条独立链路。用户刷新页面时,后台可能已经完成了一次工具查询,写出半段比较结论,又开始执行第二个工具。此时仅接收“从现在开始”的新片段,页面就会缺少之前的内容。 先区分两种读取需求。只展示已经保存的完整消息时,使用 session.transcript() 即可:它返回消息列表和这份历史对应的 asOfSeq。但它不是一个完整的流式聊天界面快照,不包含从 model/chunk 重建的进行中文本或工具参数。 如果要恢复“写到一半的回复”和工具卡片,应用需要把已提交事件整理成页面视图。聊天示例中的 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 协议,见 Service 事件接入。

回复 Agent 的问题或确认请求

假设 Agent 在生成建议前需要询问部署环境,或在执行工具前需要用户确认。通过 AgentSession 提交的任务可以挂起等待回答;问题保存在日志里,用户刷新页面、甚至服务重启后仍能看到。 用 session.pending() 读取尚未答复的交互。返回值的 key 是 requestId,页面应把它与对应的问题一起保存,提交答案时原样带回:
对于 ask_user 或外部执行产生的 external_execution 请求,把用户答案或真实执行结果交给 respond。下面的标识需要替换成页面所回答的待办 ID:
如果待办类型是 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,不必仅为人工确认更换执行方式。

中断后继续原任务

用户点击“停止”时,调用 session.interrupt() 请求当前执行在协作检查点停止。它不是立即终止工具的命令,方法返回也不代表执行已经停下;应等待任务状态变成 interrupted,再提供“继续”操作。 下面展示同一任务的停止与接续。Web 应用通常把这两步放在两个用户操作中;这里用 await 表示等待停止完成:
前端已经知道正在显示的 runId 时,可以使用 session.interrupt(runId),避免旧页面的停止操作误中断另一轮执行。resume 会恢复已保存的工作状态与尚未应用的原输入,继续原任务;业务无需重发用户消息、构造空输入或设置 turnId。 恢复使用的是 checkpoint:执行过程中保存的会话工作状态。它让 Agent 能从已提交状态接着推理,但不会恢复原 Java 线程、网络请求或工具内部执行进度,也不会回滚工具已经产生的外部效果。普通工具所需的凭据、工作文件和外部资源也必须仍然可用。 当前只能继续最新的未完成任务。已经完成或取消的任务不能 resume;有待答交互时应先 respond。如果之前通过 agent.call 或 agent.streamEvents 直接执行,中断仍使用运行实例上的 agent.interrupt;日志负责保存状态,会话入口负责安排后续任务,二者不是互相替代的机制。

服务重启后继续

重启后用相同的 agentId、userId、sessionId 和日志后端重新构建 Agent,再取得 session。此时可以直接读取历史和待办;读取本身不会触发模型调用。
start() 会检查持久队列,但不会擅自恢复已中断的任务;由应用决定何时调用 resume,或等待用户回答待办。submit、resume、respond 本身会自动开启调度,因此正常交互不必每次手工调用 start()。 关闭所属 agent 会停止调度并中断其持有的执行,已提交日志和队列由持久后端保留。如果进程被强制终止,可能还需要等待旧执行的写入租约释放或到期,新的执行才能安全接手。

核对结果未知的工具

假设工具已经在订单系统创建了订单,但进程在记录工具结果之前退出。日志能说明工具曾被调用,却不能证明订单创建成功还是失败;直接重试可能重复创建订单。 这种情况下,先通过 session.inspect().uncertainToolCalls() 找出需要核对的工具调用,再查询真实业务系统。确认结果后,把对应的工具调用 ID、工具名称和实际结果交回框架:
结果集合需要恰好覆盖检查中列出的未知调用。这个操作只保存核对结果和 checkpoint,不再次调用工具;之后再显式 resume 原任务。实际结果仍未知时,不应填一个空的成功结果来绕过检查。

session、turn 和 run 如何对应

可以把 session 理解为一段持续对话,turn 理解为用户提交的一项任务,run 理解为完成该任务的一次实际执行。一次 run 中可以多次推理、调用多个工具,并生成多条消息;它不等于一次模型调用或一条 AssistantMessage。 例如,用户在会话 S1 中要求“比较方案,发邮件前先让我确认”: 表中的 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 细看一次执行。下面从当前日志末尾确定读取范围,不会触发模型或工具:
seq 是当前会话内从 1 开始递增的持久序号,排序和续读都使用它。scan(0, through) 读取截至 through 的固定范围;需要分批续读时,使用 readAfter(lastSeq, limit),处理完成后再保存最后一条事件的 seq。eventId 用于接收端去重,不能当作顺序号。 这些日志读写和 AgentSession 的同步操作会访问存储。在 WebFlux handler 中,应使用 Mono.fromCallable(...).subscribeOn(Schedulers.boundedElastic()) 调度,避免阻塞网络事件线程;await 自身已安排好状态轮询。

事件目录

普通聊天应用不需要逐项处理下面所有事件。需要构建时间线、查询工具结果或做诊断时,再按关注的内容读取对应类型。 记录范围是模型适配器可见的请求与输出、工具和框架执行过程,不包括模型服务内部过程,也不会自动备份被引用的文件。原始请求和工具结果可能包含业务数据,面向浏览器的接口应挑选需要展示的字段。
每条 SessionEvent 的载荷都会冻结为 JSON,后续修改原对象不会改变已记录内容。

存储位置与后端配置

默认情况下,HarnessAgent 使用 WorkspaceSessionLogStore,日志跟随 Workspace 的 Filesystem 保存。Workspace 可以使用本地磁盘,也可以使用分布式存储。 创建新 Agent 实例或重启应用后,只要身份与存储配置保持一致,就能找到原会话。 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:
随后从这个 Builder 构建 Agent 即可,调用方式不变。可选后端及连接方式见分布式存储集成。内存后端适合测试,进程退出后不会保留历史。 共享存储不会让直接调用的同会话请求自动排队,这类请求仍需应用协调。使用 AgentSession 时,任务通过持久输入队列接收和排队,并由已启动的会话调度器执行。会话列表也受当前 namespace 范围限制,SESSION 隔离下的列表不能枚举其他会话。

自定义后端与备份

一般应用只需选择现成后端。需要接入自己的存储时,可以实现 SessionLogStore,或实现 AtomicSessionStorage 并复用 JournalSessionLog。它们需要支持原子版本比较写入、执行写入租约与旧写入者隔离、有序幂等提交及导出进度持久化;普通文件的读写接口不足以保证这些语义。 自定义 Filesystem 应提供 sessionStorage 能力,或者单独指定日志后端。使用 AgentSession 命令还要求 SessionLog.inbox() 支持持久输入队列;默认 Workspace 和内存日志已提供。需要列出会话时,使用 SessionLogStore.list(RuntimeContext)。 备份建议使用后端一致性快照,或先暂停写入,再保存会话记录。工作文件、沙箱内容、外部产物和工具依赖需要另外备份,只有日志不足以完整恢复所有外部环境。
每个会话在日志根目录或分区下保存如下对象,身份段使用 Base64URL 编码:
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。
sink.accept 返回应表示目标已经持久接收。导出失败后可能重复投递同一事件,目标端需按 eventId 去重;sink.name() 应跨部署保持稳定,框架用它保存各目标的导出进度。应用可以在启动、空闲时或定时任务中再次 drain() 补投,并协调多副本对同一会话和目标的导出。 需要执行时自动触发导出时,先将 sink 放入 RuntimeContext 的 SessionExportSink.CONTEXT_KEY,再首次调用当前 Agent 实例的 agent.session(ctx)。这不能替代失败后的补投安排。Service 已提供公共事件导出与 SSE,使用托管接口的业务通常不必自行实现这一层。

记录应用自己的事件

例如,人工复核或业务校验发生在工具或中间件中,希望它也出现在执行时间线上,可以从本次运行的 RuntimeContext 取得 SessionRecorder,写入带应用前缀的事件类型:
将这个方法返回的 Mono 组合进工具或中间件的调用链,才能等待事件提交。这里的 false 表示该事件只提供诊断信息,恢复状态时不要求识别它。不要在调用结束后继续保留 recorder 写入。 如果自定义事件还要参与状态恢复,需要在运行和恢复前通过 SessionEventCodecRegistry 注册校验器与状态还原逻辑。标记为 required 却无法识别的事件会阻止恢复,因此普通业务诊断事件通常使用 required=false。

迁移与分支会话

已有应用若保存的是 AgentState,可以把它作为一份起始状态导入新的空会话。需要从当前对话尝试另一条路线时,则可以建立新的分支会话,保留源会话不变。 导出或分支前应停止源执行,并处理未知工具结果、尚未结束的执行和待答交互。导入保留的是当时的状态,不会补造更早的执行历史;分支也不会自动复制原会话的完整历史、沙箱或外部资源。

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

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