OpenHuman 每轮工具时间线(Per-turn Tool Timeline):多轮对话中每条回答的过程轨迹持久化设计解析
【免费下载链接】openhumanOpenHuman is an open source personal AI for Mac, Windows and Linux — local-first memory, agent orchestration, and deep research.项目地址: https://gitcode.com/GitHub_Trending/op/openhuman
多轮 Agent 对话里,只有"最新一轮"能展示其产生回答时所调用的工具、子代理处理过程,翻看历史回答时这些轨迹全部丢失——这是 OpenHuman 会话体验中一个长期存在的缺口。本文基于仓库内的设计草案 docs/plans/per-turn-tool-timeline-history.md,完整还原其问题建模、按request_id分轮存储的磁盘布局、消息锚定方案与 reload 一致性不变量,并结合仓库现行实现给出源码级佐证。读完你将对"如何让每条回答都拥有一段可回放、可恢复的处理过程轨迹"有完整的工程认知,可直接据此理解甚至复现该子系统。
说明:关联文档自述状态为
draft / design. Not yet implemented。而当前仓库已包含大量与设计一致的实现(例如 Rust 侧按轮次存储、COMPLETED_RETENTION保留、legacy 迁移,前端turnTimelinesByThread的按requestId分键结构等)。因此下文以"设计意图 + 现行实现佐证"的双线方式展开,凡是引用到源码,均表示该机制在当前树中已可找到对应实现。
1. 问题:每条回答都应有自己的"处理轨迹"
在多轮(multi-turn)会话线程中,Agent 的每一轮回答都应该携带一条独立的"Agentic task insights" 轨迹——也就是生成该回答时用到的工具、子代理与它们的执行过程。但截止到该草案成形时,除最新一轮外,所有历史轮次的轨迹都会丢失。原因落在两端:
- 前端只持有一条时间线:每个线程在内存里只有一条
toolTimelineByThread[threadId](设计文档写作 chatRuntimeSlice.ts:567,现行代码中对应字段位于 chatRuntimeSlice.ts:638),且只渲染一次,锚定在最后一条用户消息之后。每次新的发送都会先setToolTimelineForThread([])把旧轨迹整体清空。 - 内核只保留一个轮次快照:每个线程只有一个 turn-state 快照文件,写入采用"整文件覆盖",即"最新快照胜出"。即使轮次已
Completed,快照也只会保留到该线程的下一次轮次将其覆盖为止。
叠加的后果是:重新加载(reload)后向上滚动,历史回答没有任何过程轨迹;唯一一条存活轨迹永远沉在对话最底部。
值得一提的是,同仓库里chat_interim工作已经修复了这条缺口的"叙述"一半——中期叙述(narration)现在是可持久化的线程消息,由 progress_bridge.rs 与ChatRuntimeProvider配合写入真实消息流。本设计要覆盖的则是剩下的一半:每一轮的 tool timeline。
1.1 前端单数组现状(现行代码中的证据)
当前树中 chatRuntimeSlice.ts 仍然保留"单条实时时间线 + 已定局轮次按 requestId 分键"的混合形态:
toolTimelineByThread: Record<threadId, ToolTimelineEntry[]>——实时/最新轮次的时间线,由 socket 事件驱动(chatRuntimeSlice.ts:638);turnTimelinesByThread: Record<threadId, Record<requestId, ToolTimelineEntry[]>>——历史(已定局)轮次的按轮次时间线,线程打开时由turn_state_history水合(chatRuntimeSlice.ts:649-656);turnTranscriptsByThread: Record<threadId, Record<requestId, ProcessingTranscriptItem[]>>——历史轮次的交错处理转录(narration / thinking / tool 指针),同为按requestId分键(chatRuntimeSlice.ts:657-667)。
可见"每线程一条"的历史包袱正在被"每线程每轮次一组"的结构逐步取代,这正是下文设计主线的前端落地形态。
2. 现状数据流锚点:改造必须基于的事实
在提出方案前,草案先梳理了四条必须兼容/继承的现状锚点,这些在当前代码中均可一一对上号:
2.1 快照类型TurnState
快照类型定义于 types.rs,核心结构TurnState位于 types.rs:315-344:
| 字段 | 说明 |
|---|---|
thread_id | 所属会话线程 |
request_id | 一次轮次的唯一标识(发送消息即产生) |
lifecycle | Started/Streaming/Interrupted/Completed(types.rs:20-31) |
iteration/max_iterations | 当前迭代 / 上限 |
phase | Thinking/ToolUse/Subagent(types.rs:33-40) |
active_tool/active_subagent | 正在执行中的工具 / 子代理名 |
streaming_text/thinking | 已流式的回答文本 / 隐藏推理 |
tool_timeline: Vec<ToolTimelineEntry> | 本轮的扁平工具行列表 |
transcript: Vec<TranscriptItem> | 交错转录:narration / thinking / tool 指针(带每轮单调seq) |
task_board | 任务面板快照 |
started_at/updated_at | RFC3339 时间戳 |
关键点:每轮天然携带唯一的request_id。TurnState::started(thread_id, request_id, max_iterations, now)会构建一条lifecycle = Started的新快照(types.rs:396-420),这一标识正是整份设计用来分轮存储与锚定的键。
ToolTimelineEntry(types.rs:100-135)同样支持完整的持久化细节:id/name/round/status为必填,可选的args_buffer、display_name、detail、source_tool_name、subagent、failure(PersistedToolFailure,失败的"原因 + 下一步")、output(截断的工具结果)与seq(每轮单调顺序键)。嵌套的SubagentActivity还保存子代理自身的tool_calls与transcript(思考/叙述/子工具按序交错,见 types.rs:168-174),保证重载后子代理的"思考泡"也完整可回放。
2.2 Mirror 与 Store
- Mirror:
TurnStateMirror::observe把AgentProgress事件折叠进快照;TurnCompleted事件把lifecycle标为Completed且保留快照(见 mirror.rs)。启动时遗留的非终态快照会被标为Interrupted(语义注释见 types.rs:26-31)。 - Store:文件系统快照存储(store.rs),旧布局以
hex(thread_id)键文件路径,提供put(整文件覆盖)/get/delete/list/clear_all/mark_all_interrupted。设计草案描述的"一文件覆盖 + 仅最新存活"即此旧布局,现行代码已改写为按轮次布局(见下节)。
2.3 RPC 面与前端消费链
- RPC 请求/响应类型与存储类型同置于 types.rs:346-392:
GetTurnStateRequest/Response、ListTurnStatesResponse、GetTurnStateForRequestRequest(含thread_id+request_id)、ClearTurnStateRequest/Response,并经 mod.rs:16-20 统一 re-export。 - 前端通过
threadApi.getTurnState(threadId)(现行实现见 threadApi.ts:142-149)调用,继而走hydrateRuntimeFromSnapshot把快照写回toolTimelineByThread[threadId]。
3. 总体设计:每线程保留一个"有界的已完成快照环"
设计核心一句话:每个线程维护一个以request_id为键的有界环(bounded ring),保存已完成的轮次快照,外加现有的单条 live/latest 快照;同时把每个已完成轮次的时间线锚定到该轮次产生的回答消息上。
与旧模型"一线程一快照、最新覆盖"相比,新模型把存储维度从"线程"细化为"线程 × 轮次",时间线从此不再有"只有底部一条"的必然性。设计分四个落地层次:存储、锚定、RPC 与前端、迁移与兼容。
3.1 磁盘布局目标形态
…/memory/conversations/turn_states/ └── <hex(thread_id)>/ ├── <hex(request_id_1)>.json # 已完成的第 1 轮 ├── <hex(request_id_2)>.json # 已完成的第 2 轮 └── <hex(request_id_N)>.json # live / 最新轮这一布局已在现行 store.rs 顶部 rustdoc 与路径函数中固化:
thread_dir(thread_id)→turn_states/<hex(thread_id)>/(store.rs:284-286);turn_path(thread_id, request_id)→<hex(thread_id)>/<hex(request_id)>.json(store.rs:288-294);- 根目录由
workspace_dir/memory/conversations/turn_states确定(store.rs:277-282)。
4. 存储层设计(步骤 1):按轮次 key、保留 latest 指针
4.1 按轮次写入与原子性
put把快照写入<request_id>.json,全程沿用"临时文件 + rename 原子替换"以保持既有持久性语义:先写NamedTempFile,fsync文件后再persist(rename),随后再做一次 best-effort 的目录 fsync,防止 rename 与下一次落盘之间断电丢失快照(Unix 打开目录句柄sync_all,Windows 依赖 NTFS journaling 而 no-op,见 store.rs:508-520)。所有变更经过一个进程级 mutex 串行化,避免进度消费者 flush 与 RPC handler 读取同一文件交错(store.rs:44)。
4.2 Completed 写入触发保留策略(N = 20)
草案建议:写入Completed快照时,按completed_at把该线程目录裁剪到最近 N 个已完成轮次(提议 N = 20),让历史始终有界——语义上呼应时间线注册表的REGISTRY_SOFT_CAP"绝不无界"哲学。
现行实现将该常数固化为COMPLETED_RETENTION: usize = 20(store.rs:40-43),put在写入Completed快照后调用prune_completed_locked(store.rs:101-103):过滤出全部Completed轮次,若超过 20 个则按updated_at新→旧排序,删除窗口之外的多余文件(store.rs:468-496)。非终态轮次(至多一个 live 轮)不受裁剪。
4.3 latest 解析:不额外引入指针文件
草案提供了两种"latest 指针"方案——latest.json指针副本,或扫描目录取最大started_at(指针文件可省掉热路径get_latest上的目录扫描)。现行实现选择了后者且更轻:get(thread_id)读取线程目录全部可解析快照,用latest_turn选出started_at最大者(并列时再比较updated_at)作为最新/在飞轮次(store.rs:499-506)。list()也保持旧契约——每线程只返回最新一条,供冷启动时列出遗留中断轮次。
4.4 新 API:按轮次读与按线程列举
草案要求:get(thread_id)保持返回 latest(对既有单轮 RPC 向后兼容);新增get(thread_id, request_id)与list_completed(thread_id)(后者只回元数据)。
现行 store.rs 的实现面比草案更宽,全部以同名 free-function 暴露给 RPC 层(store.rs:534-568):
| 方法 | 语义 |
|---|---|
get(thread_id) | latest 轮(最大started_at),兼容旧单轮调用方 |
get_turn(thread_id, request_id) | 按request_id精确取某轮(store.rs:117-125) |
list_thread(thread_id) | 某线程全部轮次,started_at新→旧(store.rs:170-180) |
list() | 每线程 latest 一条,冷启动中断轮次列举用 |
delete(thread_id) | 删除整线程目录与遗留 flat 文件(store.rs:129-148) |
clear_all() | 清空一切(含不可解析文件),返回删除的 JSON 文件数 |
mark_all_interrupted(now) | 启动时把所有非终态轮次标Interrupted,跳过Completed/Interrupted(store.rs:241-266) |
其中mark_all_interrupted的"跳过已完成/已中断"语义与草案完全一致:已完成的轮次要被保留以便重启后回放;快照被标为Interrupted时同时清空active_tool/active_subagent并刷新updated_at。注意读取线程目录时,单个不可解析文件只会被记录日志并跳过,不使整体失败(store.rs:304-329)。
5. 轮次 ↔ 消息锚定(步骤 2):把时间线贴到"它产生的回答"上
存得住之后,前端必须能把每个已完成轮次映射到它产出的回答气泡。草案给了两个方案并推荐 B:
- 方案 A:按用户消息锚定。在该轮次触发的用户消息上方渲染轨迹。需要在快照上记录触发它的用户
message_id。简单,但轨迹被放在问题之上而非回答之上,视觉归属感差。 - 方案 B(推荐):按产生的助手消息锚定。把某轮追加的助手消息盖上该轮的
request_id。addInferenceResponse(原 threadSlice.ts:185 所在函数)以及chat_segment/chat_done/chat_interim处理器都持有request_id,把它织入extraMetadata.requestId并持久化即可——ThreadMessage.extraMetadata本来就是开放字段。
方案 B 之所以胜出,是因为它reload 一致:消息本就携带extraMetadata从线程存储重载,每轮时间线又从各自的按轮快照重载,live 视图与 reloaded 视图天然不会分歧。
5.1 现行实现中的 requestId 印章与分组
这套机制在当前前端代码里已经层层可见:
- 写侧印章:
ChatRuntimeProvider在收到带event.request_id的事件时为消息补上extraMetadata: { requestId: event.request_id }(ChatRuntimeProvider.tsx:987);Conversations.tsx里对停止态消息同样会写extraMetadata: { stopped: true, ...(requestId ? { requestId } : {}) }(Conversations.tsx:1416)。 - 读侧分组:timeline 的
selectors从message.extraMetadata?.requestId推导该消息所属的turnId(selectors.ts:55-59),ChatThreadView也用msg.extraMetadata?.requestId决定消息与轮次的归属(ChatThreadView.tsx:362)。 - 渲染侧:历史轮次的洞察面由独立组件
PastTurnInsights承担——它取出该轮turnTimelinesByThread[threadId][requestId]的时间线,落到可复用的<ToolTimelineBlock entries={entries} />上(PastTurnInsights.tsx);时间线块的折叠/展开渲染逻辑沉淀在 ToolTimelineBlock.tsx("已定局自动折叠"的既有行为由此复用)。
6. RPC 与前端状态(步骤 3):按轮次索引而非整体覆盖
6.1 新 RPC 面
草案拟定新增两类 RPC:
threads.turn_state_list→[{ requestId, lifecycle, startedAt, completedAt, toolCount, subagentCount }](纯元数据,列表轻量);threads.turn_state_get(threadId, requestId)→ 完整PersistedTurnState;- 既有
get_turn_state(threadId)保留用于 live 轮。
现行实现的实际 RPC 命名与草案略有演进(threadApi.ts:142-193):
| 前端方法 | RPC method | 语义 |
|---|---|---|
getTurnState | openhuman.threads_turn_state_get | live/latest 轮 |
listTurnStates | openhuman.threads_turn_state_list | 每线程 latest(冷启动) |
getTurnStateHistory | openhuman.threads_turn_state_history | 某线程的按轮次历史,新→旧 |
getTurnStateForRequest | openhuman.threads_turn_state_get_turn | 按(thread_id, request_id)精确取一轮 |
clearTurnState | openhuman.threads_turn_state_clear | 清空线程快照 |
其中getTurnStateForRequest正是为"按需懒加载某轮完整时间线"准备的(见 6.3)。
6.2 前端状态形状:从一维到二维
草案要求把toolTimelineByThread: Record<threadId, Entry[]>升级为toolTimelineByThread: Record<threadId, Record<requestId, Entry[]>>并新增liveRequestIdByThread指针:
hydrateRuntimeFromSnapshot写入该轮requestId名下;setToolTimelineForThread瞄准 live 的requestId,发送新消息时不再清空历史。
如上文第 1.1 节所述,现行 slice 已把"历史轮次"单独放到turnTimelinesByThread(threadId → requestId → entries)与turnTranscriptsByThread(threadId → requestId → items)两个二维映射中,与单条的 livetoolTimelineByThread并存,正好对应草案"live 单独一条 + 历史按轮分键"的意图。
6.3 渲染循环与懒加载
渲染时,对每个requestId组的首条助手消息,在其上方渲染<ToolTimelineBlock entries={byRequest[rid]}>(沿用已定局自动折叠行为);live 轮保持现有的在飞渲染。
懒加载策略:线程打开时只调用turn_state_list(廉价元数据列表),当某个(折叠的)洞察块首次被展开时才去turn_state_get拉该轮的完整时间线——这样打开超长线程不会一次性抓取几十个完整快照。threadApi.getTurnStateHistory的注释同样说明"cheap enough to call on thread open; full timelines can be lazily re-fetched per turn via getTurnStateForRequest"(threadApi.ts:159-163),二者互为印证。
7. 迁移与向后兼容(步骤 4)
老用户磁盘上已存在旧单文件快照(<hex(thread_id)>.json),设计必须平滑过渡:
- 旧文件一次迁移:首次访问时把 flat 文件读入并按
<hex(thread_id)>/<request_id>.json重写。若某条旧快照没有request_id,就把它键为legacy,作为该线程唯一的一段历史轮次。 - 无
requestId消息回退:迁移前的消息没有extraMetadata.requestId,此时回退到旧的单锚行为——整条线程只在底部渲染一次 live/latest 时间线——让旧线程"优雅降级"而不是丢轨迹。
现行实现把迁移做成了 store 的自愈步骤:
- 每次
put/get/list/delete前调用migrate_thread_locked(store.rs:375-405):存在 flat 文件则读入 →write_turn_file写入按轮文件 → 删除 flat;写失败则保留 flat 并告警,幂等; list/mark_all_interrupted走migrate_all_legacy_locked(store.rs:408-441),扫描根目录批量迁移;- 迁移用
write_turn_file(无迁移/保留副作用的最小原子写),避免递归触发保留逻辑(store.rs:445-463)。
前端侧的回退语义也已在selectors注释中明确:Phase 4 之后的消息携带extraMetadata.requestId,更老的消息没有,推导时对后者做兼容处理(selectors.ts:55-59)。
8. Reload 一致性:需要捍卫的不变量
这是整份设计的核心不变量:live 视图与 reload 后的视图必须渲染一致。达成路径全系于一个键——requestId:
- 助手消息在
extraMetadata里携带requestId; - 时间线按
requestId存储/获取; - 两端按同一个键关联,不存在仅存活于会话内的状态。
这恰好规避了该代码库反复警告的失效模式(例如preserveLiveSubagentProse注释所警示的"live 专用状态在 reload 后漂移"):任何轮次的过程轨迹都能仅凭thread_id + request_id从持久层重建,无论用户是切线程、重启进程还是冷启动后重新打开。
9. 测试计划
草案的测试矩阵按三层划分,覆盖新增行为的边界:
- Store(Rust):按轮 put/get/list、保留裁剪到 N、latest 指针正确解析、legacy 单文件迁移、
mark_all_interrupted对每线程目录生效。对应测试模块挂在 store.rs:570-572 的store_tests.rs(store_tests.rs)。 - Mirror(Rust):一个
Completed快照要在自己的request_id下被保留,后续轮次不得覆盖它。测试见 mirror_tests.rs。 - 前端:
hydrateRuntimeFromSnapshot写入requestId名下;渲染循环按requestId分组并在每组首条助手气泡上方渲染每轮时间线;无requestId的 legacy 消息回退到单锚。对应扩展 Conversations.render.test.tsx(其用例已经出现带requestId: 'req-1'/'req-2'的多轮消息构造,见 Conversations.render.test.tsx:655-679)与chatRuntimeSlice测试。
10. 分步上线与工作量评估
草案把整个改造切成四个各自可独立发布的步骤,UI 只到最后一步才变化:
| 步骤 | 内容 | 变更面 | UI 是否变化 |
|---|---|---|---|
| 1 | Store 按request_id分键 + 保留策略 + legacy 迁移 | Rust(store.rs) | 否(沿用旧单轮 RPC) |
| 2 | 助手消息盖章extraMetadata.requestId | 前端(增量) | 否 |
| 3 | 新增turn_state_list/turn_state_get(requestId)RPC | Rust RPC 层 + threadApi.ts | 否 |
| 4 | 前端按requestId分键 + 每轮渲染,legacy 回退 | slice + 渲染循环 | 是 |
工作量评估为Medium-large(中大)。第 1 步是核心风险点(持久化格式 + 迁移),第 2–4 步机械但会触及渲染循环与 slice 形状。因此建议先合入第 1–3 步(对用户不可见),再以多轮线程的快速手动 QA 验证第 4 步。
结语:从"一条轨迹"到"每轮一段历史"
Per-turn tool-timeline history 的实质,是把 OpenHuman 会话体验从"只记得当前这轮 Agent 干了什么"推进到"记得这条线程里每一轮各自干了什么"。它并不发明新概念,而是把一个已经存在、却只活在内存里最后一轮的状态,通过request_id这条天然的轮次身份证,沉淀为有界的、可迁移的、与消息流可对齐的持久化事实——最终换取的是一个简单而有力的不变量:live 与 reloaded,所见即一致。理解这份设计,也就理解了 turn-state 子系统的存储基调:threads/turn_state/三个文件(types.rs、store.rs、mirror.rs)加上前端 slice 的二维索引,共同回答"一条回答是被什么过程产生的,且永远可以被找回"。
【免费下载链接】openhumanOpenHuman is an open source personal AI for Mac, Windows and Linux — local-first memory, agent orchestration, and deep research.项目地址: https://gitcode.com/GitHub_Trending/op/openhuman
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考