OpenProgram Docs

Session DAG — 设计#

本文是 agent 执行记录的权威设计:数据模型、边与不变量、分支与 spawn、上下文渲染、 上下文装配、压缩。模型选型的论证见 ../../research/execution-trace-model-selection.zh.md。 调用流程图见 ../agent-call-flow.svg。 可视化渲染规范(布局、连线、图例、默认可见性)是 rendering.zh.md——绘制以它为准,本文只讲语义。

模型可视化

1. 概述与动机#

整个会话是一张有唯一根的 DAG。每条用户消息、每次 LLM 调用、每次函数调用都是同一 张图里的节点,共享一个单调递增的 seq。这张图同时是:

  • 持久化记录——发生过什么的唯一存底;
  • 运行时上下文——每次 LLM 调用的上下文都是对图中一条路径的渲染;
  • 展示来源——聊天流、调用树、minimap 都是同一批节点的投影。

这种融合正是设计的核心。可观测性系统(LangSmith、Datadog)把每个请求拆成独立 trace、用 session 标签分组,因为它们只做事后观察、从不回读记录。本系统要回读: render_context 沿同一张图、同一个 seq 取历史,所以各轮必须连成一张图。唯一根 + 共享 seq 是"一张图"的硬约束——没有根,每个顶层节点就是各自孤立的图。

这个设计的核心在于融合本身:记录下的调用树就是运行时上下文,每次调用按 frame 作用域和逐函数的 expose 设置查询它,且所有节点永久保留(支撑 fork 与 replay)。构成 它的单项技术(调用栈追踪、图 fork)都常见,把三重身份合在一张图上的整体做法不常见。

2. 数据模型#

节点(Call)#

一个数据结构覆盖一切。LLM 调用永远是同一种 llm 节点,不因触发方是用户还是函数而 分裂。

Call:
  id           唯一标识
  seq          单调递增整数,全局时间序(唯一排序键)
  created_at   墙钟时间(给人看,不用于排序)

  role         "user" | "llm" | "code"   ← 只决定渲染,不分裂本质
  name         模型 id / 函数名 / 用户名

  input        prompt / 函数参数 / None
  output       回复 / 返回值 / 用户文本
  status       running | completed | error | cancelled

  caller       谁调用了我(子调用父 id);非子调用节点为空
  predecessor  聊天里谁在我前面(对话链父 id);
               顶层 schema 字段——这条边唯一的存储位置
  reads        本次 LLM 调用读了哪些节点(渲染用引用,不是结构边)
  metadata     token 用量 / 模型 / source / expose / tool_call_id …
               LLM 叶子字段对齐 gen_ai.*

定义在 openprogram/context/nodes.py

三种 role 与 ROOT#

场景 role caller predecessor
会话根(ROOT) user(特殊,display=root
用户发消息 user ROOT 上一轮 llm 回复;会话首节点用哨兵 "ROOT"
LLM 回复 llm 本轮 user(顶层)或所在 code 节点 本轮 user
LLM 调工具 code 该 llm 节点
用户手动调函数 code 当前分支 head(根层为 "ROOT"
函数内调 LLM / 子函数 llm / code 所在 code 节点

循环不占节点:跑 N 次的循环 = 同一父节点下的 N 个兄弟(按 seq 排序);可视化可以折 叠成 ×N,但数据仍是 N 个节点。一次函数调用只有一个 code 节点——绝无 anchor、 placeholder 或任何辅助节点。

status 词表#

所有节点统一一套:running | completed | error | cancelled。聊天与函数路径共用; error 节点带结构化 type/trace 元数据,用户取消写 cancelled,绝不写 error

3. 边与不变量#

两条边,绝不混为一条#

字段 含义 谁有
子调用边 caller 谁调用我执行 只有真被子调用的节点;普通顶层回复留空
对话链边 predecessor 聊天顺序上我接在谁后面 user / llm 节点

两条边都有向无环。caller 让所有顶层节点汇聚到 ROOT(一张连通图);predecessor 表达聊天顺序并区分分支。二者正交:

一张图(共享 seq,唯一根)。每个节点:caller(C) / predecessor(P)
ROOT
├ user1  seq0  C=ROOT  P="ROOT"   ┐ 顶层 user 经 caller 挂在 ROOT 上;
│  └ llm1 seq1 C=user1 P=user1    │ 对话顺序经 predecessor 成链:
├ user2  seq2  C=ROOT  P=llm1     │   user2.P=llm1, user3.P=llm2
│  └ llm2 seq3 C=user2 P=user2    │ fork = 同一 predecessor 有多个对话子节点
├ user3  seq4  C=ROOT  P=llm2     ┘

为什么要两条边:分支必须靠 predecessor 区分。用户重发消息时同一位置长出两个子节 点,仅凭 seq 分不清哪个子节点属于哪条分支线。单边模型(caller + seq)表达不了分支。

predecessor 是 schema 字段#

predecessorCall 的顶层字段——唯一存储位置。序列化只写顶层;没有 metadata 镜 像,没有旧格式读取路径。把这条边放进 schema 而不是事后校验 metadata,才让读方可以依 赖它:链接错的分支无法被 linter 修复,所以 append 路径必须拒绝制造它。

写入不变量#

在 store 的 append 路径强制执行(openprogram/store/session/session_store.py): 每个 ROOT 级对话节点(role 为 user/llm、无真实 caller)必须带 predecessor 违规 append 抛 PredecessorMissingError,而不是悄悄在 ROOT 处 fork 会话。合法例外:

  • 会话首节点显式根 fork——带哨兵 predecessor="ROOT"(不是空),所以重试 首条消息会产生不变量允许的合法 ROOT 级兄弟;
  • spawn 分支根——只能经 spawn_branch()(§4)创建,predecessor=Nonecaller 指向发起节点;
  • ask_user 应答节点——带非 None input 的 user 节点是调用内的被调方回复, 不是对话轮;
  • 压缩 summary 节点——按 §8 是合法链上成员。

读取不变量#

get_branchlist_branches 纯沿边行走——没有 caller 回退,没有 seq 拼接,没有 猜测。无 predecessor 的节点必须是合法分支终点(spawn 根、ROOT 本身、会话首节 点);否则就是坏数据,抛 BrokenPredecessorChainError 并带上出问题的节点 id。坏数 据浮出水面,绝不被绕过去。

4. 分支与 Spawn#

Fork#

分支是同一位置上的另一种可能。分支节点的 predecessor 与被替换节点完全相同—— 同一 predecessor 有多个对话子节点即为 fork。不存在特殊节点类型:

场景 被替换节点 分支节点 共享边
用户重发消息 user2 (P=llm1) user2' (P=llm1) predecessor
LLM 重试 llm1 (P=user1) llm1' (P=user1) predecessor
工具重试 code (C=llm1) code' (C=llm1) caller

失败与重试#

error 是终态,不是缺失态。 抛异常的一轮和成功的一轮走完全相同的收尾:节点以 status=error 写入,本轮提交进 git,head 停在这个 error 节点上。失败的这一轮是会话 里发生过的事实,记录里就得这么写。

在错误路径上跳过收尾,会恰好在出问题的地方给 git 时间线留一个洞——而那正是历史最值 得留存的位置。它还会让重试从一个从未写过 commit 的 predecessor 上分叉。用户取消同样 按终态收尾,写 status=cancelled

错误路径上仍然执行的收尾步骤,是那些维持记录完整性的步骤:git 提交、项目提交、 shadow-git 提交、快照淘汰。那些以"已有完整回复"为前提的步骤——context-commit 回填、 用量反馈、自动起标题——对没有回复的一轮没有意义,跳过。

重试不需要单独机制。它就是一次普通 fork:重试节点取失败节点的 predecessor,这一点使 它成为兄弟而非后继。由普通规则直接推出两个结果:

  • 失败的那条线保留。 error 节点留在图里且仍可达。切到它那条分支就能看到当时到底 发生了什么。
  • 失败的那条线不进入重试的上下文。 render_context 沿活跃分支行走,error 节点不 在这条分支上。重试永远看不到它正在重试的那个错误。这里没有任何按 status 的过滤,分 支隔离本身已经做到了。

Spawn#

SessionStore.spawn_branch(session_id, caller_node_id, *, source, name=…) 是开 spawn 分支的唯一原语。它创建分支根 user 节点(predecessor=Nonecaller=caller_node_idmetadata.sourcemetadata.spawn_branch_root=True), 注册为 head,返回节点 id。spawn 调用方(task runner、协作消息、后台 agent)一律调 用原语、绝不手工组装边,新调用点不可能把边写错。

spawn 分支根挂在 ROOT 上:它的 caller 指向发起它的节点,经该节点维持单连通 图不变量。(例外:跨会话 spawn 的分支根指向另一会话的图;在本会话内挂在 ROOT 上, 渲染层打 ↗ 徽标——见 rendering.zh.md 图例。)

spawn 分支的上下文是干净的:spawn 分支上的 get_branch 止步于 spawn 根,不会经 caller 边漏进父分支。spawn 分支的聊天视图只显示本分支自己的历史。

完成通知锚定在 HEAD#

异步子 agent 跑完,会往派它的会话里写回两样东西:attach 指针,锚在发起调用的那一轮, 因为调用就发生在那里;以及一条通知轮(metadata.source = "task_followup"),锚在会话 当时的 HEAD,并像任何一轮那样推进 head。

两者锚点不同,是因为说的不是一件事。attach 指针是一次调用的记录,就该待在调用旁边; 通知是对话里新的一轮,就该待在对话末尾。

这正是 N 个子 agent 不会被回答 N 次的原因。把每条通知都锚在发起它的节点上,它们就成了 同一轮的兄弟——三个子 agent 跑完就是三条平行分支、各自一个回复,用户发了一条消息,眼看 着它被回答三遍。锚在 HEAD 上,它们连成一条链:

… → 发起轮 → 通知₁ → 回答₁ → 通知₂ → 回答₂

runner 让 TurnRequest.branch_from 保持 INHERIT_PARENT,派发前也不再回退 head,锚定 交给 dispatcher 的普通追加路径完成。并发用每个投递会话一把锁处理 (TaskRunner._followup_lock):两个子 agent 同一毫秒跑完,也依次占用轮次,第二条读到的 HEAD 已经包含第一条的回答。

5. 头指针与分支管理#

  • head:会话维护一个 head_id——当前活跃分支的末端。每条写路径都把它推进到真 实节点 id;函数调用完成后 head 移到实际 code 节点,绝不指向 placeholder。悬空的 head 会让分支行走不可达、会话渲染为空。
  • get_branch(session_id, head_id):从 head 沿 predecessor 链走到终点,返回该分 支的线性历史。
  • list_branches(session_id):枚举分支末端(无对话子节点的对话节点)。"main"末 端由最早的对话根沿首子路径走到底得出——正好匹配用户视觉上认作主干的 lane-0 直 线。spawn 根从不出现在 predecessor 子节点表里(其 predecessor 为 None),无需特 判。分支名存在会话 meta 的 branches: {head_id: name}

6. 上下文渲染#

所有上下文都经由 render_context 从图中取出。哪些节点进入上下文,由一条判定规则 决定:

一个节点进入渲染,当且仅当它沿 caller 上溯的最近 ROOT 级祖先位于 head_id 的 predecessor 主链上,且 frame/expose 规则放行。

具体过程:render_contexthead_id 沿 predecessor 链走到分支起点,得到当前分 支的主链;对主链上的每个节点,再按 frame 与 expose 规则过滤它的 caller 子树,通过 的节点进入渲染。seq 只用来排序,不参与上述判定。因此分支之间的隔离由行走过程天然 保证,不需要引擎在取出之后再做过滤。引擎只做三件事:解析 head、调用 render_context、把结果交给 render_dag_messages 翻译成 provider 消息。

ROOT 本身不算 ROOT 级祖先。所有顶层对话节点都带 caller="ROOT"(§3——正是它让全图 连通),若展开 ROOT 的 caller 子树,会把会话里所有兄弟分支重新放进来。ROOT 级节点 的"最近 ROOT 级祖先"就是它自己;行走绝不展开 ROOT。

frame 语义:

  • 顶层聊天(frame = −1):主链上每一轮全量可见——累加;该分支的所有历史轮平铺 喂入。
  • 函数内(frame = 该 code 节点的 seq):frame 前的历史 + 函数自身 frame 内的进 展可见;其他函数的内部按各自 expose 弹出(默认 io 只暴露输入输出)。

原语是纯函数:读路径不写盘。必须落盘的事(大节点 spill)发生在写路径(§8)。

工具节点的渲染:带 metadata.tool_call_id 的 code 节点(模型 tool_use)渲染为真 ToolCall/ToolResult 对、归入所属 llm 节点的 assistant 消息;不带的(直接函数调用) 渲染为文本对。两个视图投影同一份数据:聊天流(顶层 user + llm 按 seq,嵌套折叠)与 调用树(沿 caller 全展开,循环兄弟折叠 ×N)。

7. 上下文装配#

一个 system prompt,一个装配器#

全项目只有一份 system prompt(身份 + 项目记忆 + 统一工具列表 + skills),由唯一装 配器 context.build_system_prompt(agent_profile, tools, mode) 产出,所有模型调用 共享——顶层聊天与函数体内一视同仁。预算统计的就是实际发出的那个字符串;装配器输出等 于线上输出。

prompt 默认从会话开始到结束恒定。恒定前缀让 provider KV 缓存命中最大化,函数内的模 型也拿到与聊天模型相同的项目背景。推论:

  • 不拆分成聊天版和函数版。
  • 没有"可变尾段":工具列表也统一,不随调用点变化——一变前缀就变,长上下文全部 miss。
  • 例外走定制层:个别调用想要精简 system,在调用点显式声明,自愿承担 cache miss。
  • 函数内防误调工具(如自递归)靠用户轮开头的情境提示 + 递归深度上限兜底——绝不靠改 system 工具列表。详见 ../execution/agentic-self-recursion.zh.md

prompt 被记录,而非隐含#

装配出的 prompt 哈希一旦变化(会话开始、工具集变更、plan 模式切换),store 就在当 前分支追加一个 role=code 节点,name="context/system_prompt"caller=ROOT、 output 为全文。渲染时取主链上最新的该类节点作为线上 system 消息。任何历史调用的 replay 都能复现当时实际发出的 prompt。不引入第四种 role;context/* 名字保留并在聊 天视图隐藏(与隐藏 summary 节点同一机制)。

memory prefetch 位于用户轮内#

预取的记忆渲染为当次用户节点线上消息内的前缀块,并存入该节点的 metadata (memory_prefetch)。system prompt 与工具段跨轮字节级稳定(历史继续命中缓存), replay 看到的就是模型当时看到的。该块不参与老化;与本轮其他用户内容一样随轮消亡。

多模态内容#

图片与文件就是节点内容,与文本无异——没有注入 hook。节点存引用(正文放附件目录), 内容完整又不撑爆搜索索引。render_context 取节点即取其全部内容;渲染按引用加载图 片。

8. 压缩与老化#

summary 节点入链#

压缩是 append-only 的插入。summary 节点为 role=llmname="context/summary"predecessor = 它覆盖的第一个节点的 predecessor、metadata.covers_ids = 它所替代的链节点的确切 id 列表(用 id 而非 seq 区间——姐妹分支的 seq 交错)。 其余一切不变:保留尾段保持自己的 id 和 predecessor,不改任何边,HEAD 原地不动 (append 规则只在链延长时推进)。渲染应用链段替换(context/compaction.md 第三节):完整包含覆盖链段的链渲染为 [summary, 保留尾段…];其他链按原文渲染。 压缩是滚动摘要——每份新摘要吸收上一份,extra_meta._last_summary_id 标记唯一 现役者,被取代的摘要是惰性遗迹。

WebUI 载荷把 covers_ids 加上被覆盖轮次的 caller 子树后下发到 summary 行 (webui/graph_builder.py),渲染器画胶囊和它的折叠时因此不必做 seq 运算——见 dag/rendering.md 第九节。完整规范:context/compaction.md。

老化边界只进不退,渲染可精确重放#

尾轮老化边界只在轮提交时推进,绝不在轮中途(逐调用滚动的边界每次调用都打破缓存前 缀)。每个 llm 节点在调用发生的那一刻记录 metadata.render_manifest = {policy_version, aged_before_seq, spilled: [...]}。 replay 一次调用 = 用 manifest 记录的策略渲染,而不是今天的策略——同一张图任何一天渲 染出相同字节。

写时 spill,单一管线#

超过阈值的节点在记录时落盘 spill(一次、确定性),绝不在碰巧被渲染时——读路径保 持无副作用,正是 §6 的要求。DAG 渲染是唯一的上下文管线:没有回退装配路径。渲染 抛错则本轮带错误可见地失败。静默回退会掩盖坏掉的管线;响亮失败才是特性。

9. 存储层#

会话持久化在 git 后端的 store(openprogram/store/session/):

  • 每会话一个磁盘上的 GitSession,位于 <state>/sessions/<id>/
  • 每会话一个内存中的 SessionMemoryIndex,懒加载,持有行走所用的 node-by-id / children-by-predecessor 索引;
  • head_id 与分支名存在会话的 meta.json
  • store 只持久化原始节点 + meta;context commit 属于 commit 子系统。

@agentic_function 的函数体在 spawn 的子进程里跑(全新解释器而非 fork——父进 程已加载 PyTorch/libomp,fork 后子进程首次 BLAS 调用会 SIGSEGV),stop 可对进程组 SIGKILL。子进程用自己的 SessionStore 写 code 子树;父进程执行结束后 invalidate 缓 存以读取磁盘真相。函数调用的单一事实来源是 SessionStore 的 code 子树;实时 WebSocket 帧与刷新加载都是它的投影,必须产出同一张卡片。

附录:实现状态#

本文档的每一节都已实现。数据模型、边、不变量、spawn 原语、纯沿边分支行走 (§2–§5)、§6 的路径原生渲染、§7(唯一装配器、context/system_prompt 节点、 memory prefetch 迁移)与 §8(基于 covers 的 summary 节点、只进不退的老化边界、 render manifest、写路径 spill、单管线强制)在代码中均已成立;细节以 openprogram/context/nodes.pyopenprogram/context/components.pyopenprogram/context/system_prompt_node.pyopenprogram/context/aging.pyopenprogram/context/spill.pyopenprogram/store/session/session_store.py 的代码为准。

因此压缩不再需要写入不变量的豁免:summary 节点带着它所覆盖区间的 predecessor, 像任何其他节点一样入链。context/summary 是唯一对聊天视图保持可见的 context/* 名字——它的输出是替代被覆盖区间的真实对话内容,而非管线机械。

为消融准备了五个环境开关,全部在调用时读取,因此一次实验可以只动一个变量而无需 重新导入:OPENPROGRAM_TOOL_AGINGOPENPROGRAM_TOOL_AGING_TAIL_TURNSOPENPROGRAM_TOOL_AGING_MAX_RESULT_CHARSOPENPROGRAM_NODE_SPILL,以及 OPENPROGRAM_EXPOSE_DEFAULT(它只改变未指定的 expose= 的含义——显式指定始终优先)。

相关文件#

  • openprogram/context/nodes.py — Call schema + render_context、covers 略去
  • openprogram/context/components.py — 唯一的 system prompt 装配器(§7)
  • openprogram/context/system_prompt_node.pycontext/system_prompt 记录 + context/* 隐藏(§7)
  • openprogram/context/aging.py — 棘轮式老化边界 + render manifest(§8)
  • openprogram/context/spill.py — 写路径大节点 spill(§8)
  • openprogram/context/persistence.py — 基于 covers 的 summary 节点(§8)
  • openprogram/context/render.py — render_dag_messages
  • openprogram/store/session/session_store.py — append 不变量、get_branch、 spawn_branch、list_branches
  • openprogram/agent/dispatcher/__init__.py — 聊天入口、agent loop
  • openprogram/agentic_programming/runtime.py — 函数体内模型调用
  • rendering.zh.md — 可视化渲染规范(绘制的权威)
Last updated · 2026-08-13