跳到主要内容

数据截至 (上游 commit fc74d079a18c)

会话、转录与上下文管理(原记忆与混合检索)

30 秒导读: 一个 agent 要"记事",在 v3 里被拆成三个显式问题:对话怎么持续(Session: 把有限流程包进无限 LOOP 边界,靠通道收发消息)、模型每轮看什么(transcript:从循环 状态确定性投影出的转录——不是存出来的,是算出来的;内容躲在 blob 引用后面)、 给多少上下文(ContextPolicy 四档作用域 + 硬 token 预算,超了就显式省略并告诉模型)。 至于 v1 那套 docs/pgvector/trigram 混合检索——已随平台整体移除,下一节有一张对照表专门交代。

本章聚焦"记忆/上下文"这一支。会话在执行引擎里的 durable 形态见 02;Reasoner 的声明见 01


1. 先把旧账算清:什么被移除了

本章文件名沿用了 v1 时代的"记忆与混合检索",但主干里以下机制全部不存在:

v1 机制状态备注
entries/entry_relations 表(对话历史 DAG)已移除v1 分支专属
docs/doc_owners 表 + 切块已移除同上
pgvector 向量检索 / tsvector 全文 / pg_trgm已移除整个 memory-store 目录不存在
DBSF 分数融合(search_hybrid)已移除——
递归摘要 rec_sum被替换→ 本章 §4 的 SUMMARY 作用域 + summarizer
全库 grep 无 vector/embedding/RAG——julep/ 包内零命中

v3 的立场:框架不内建 RAG。 需要知识库检索,就接一个记忆类 MCP 服务器(工具面走第 04 章的冻结/preflight);框架自己只管"对话内上下文"的纪律。本章剩下的篇幅讲这套纪律。


2. 这是什么(零基础也能懂)

一句话定义: v3 的"记忆"= Session(结构) + transcript(投影) + ContextPolicy(预算),三者都是显式声明、确定性求值的,没有隐式的"自动记忆"。

类比:

机制类比
Session一根电话线:拨通(开始)、说话(recv/emit)、挂断(closed)
transcript通话备忘录:每轮"谁说了什么"自动记下,原件存在档案室(blob)
ContextPolicy给备忘录的阅读限额:全文 / 摘要 / 只看当轮,超页数就裁旧的并注明

用起来什么样(本地会话)——摘改自 examples/session_demo.py 的形态:

# 示意,非源码:本地会话的最小用法
@session # julep/session.py:187
def turn(msg, state):
... # 一轮的有限流程,返回下一轮的 carrier

ch_in, ch_out = Channel[dict]("in"), Channel[dict]("out") # julep/session.py:43
async for event in drive_session(session, feed=..., ...): # :823
if event.is_emit():
print(event.payload) # 每轮的输出

3. Session:LOOP 边界 + 类型化通道

3.1 结构:一个 Session = body + 双通道

Session(julep/session.py:75)只有四个字段:body(LOOP 体,一个有限 IR)、 init(初始 carrier)、in_channel/out_channel。模块 docstring 的概括最精炼: "cata inside / ana outside"(julep/session.py:1)——解释器始终在做有限的 catamorphism(单轮求值);喂消息的 anamorphism(无限展开)在外面。

IR 层怎么表达。 Op.LOOP(julep/kinds.py:14)节点带 channels(绑定的 ChannelRef 列表,julep/ir.py:105)和可选 state_schema(carrier 的 JSON Schema)。 解释器的 LOOP 分支无限迭代 body,直到收到 SessionClosed(julep/execution/interpreter.py:96) 才退出;split 模式下 body 可以把中间输出从 out 通道 emit 出去而不打断 carrier (julep/execution/interpreter.py:295 起的 LOOP 分支)。

3.2 通道:recv/emit 是保留工具

通道读写不是新原语——它们是第 03 章说过的保留工具 __recv__/__emit__ (julep/ir.py:37/:41),组合子 recv(channel)/emit(channel, value) (julep/derived.py:197/:210)。解释器把 __recv__ 翻译成通道阻塞接收(带可选超时)、 __emit__ 翻译成顺序输出(julep/execution/interpreter.py:390 _eval_prim)。

收到的消息和 carrier 的关系是设计里容易忽视的一手:recv 的返回值是 {"carrier": value, "msg": msg}——carrier 原样透传,消息挂在新键上。这让"等一条 消息"不会打断数据流主线的连续性。

3.3 事件流:SessionEvent

SessionEvent(julep/session.py:85)是会话的规范化事件:emit(通道输出,带 seq)、 turn_started/turn_doneerrorclosed。本地驱动器 drive_session (julep/session.py:823)消费它们;durable 侧 SessionWorkflow (julep/execution/harness.py:1759)同样以轮为边界——continue-as-new 只发生在轮边界 (_should_continue_as_new,julep/execution/harness.py:2141,注释明言"绝不能在 body 中途")。

3.4 durable 会话存储:cursor 乐观并发

SessionStore(julep/execution/session_store.py:60)是四个方法的协议: load/commit(循环状态)与 load_value/commit_value(carrier 值),外加 blob 存取。 提交带 (session_id, base=Cursor),冲突抛 CursorConflict(:56)——乐观并发控制; 互斥则完全委托给 Temporal "一个 workflow id 只能有一个运行中执行" 的保证,所以 AgentWorkflow 强校验 session_id == workflow_id(julep/execution/harness.py:2498 起)。 activity 侧的入口是 loadState/commitState/loadValue/commitValue (julep/execution/effects.py:670/:676)。


4. transcript:派生的、躲在引用后面的转录

4.1 "派生,不存储"

julep/transcript.py:1 的 docstring 开宗明义:A transcript is derived, not storedtranscript_for(julep/transcript.py:128)从 agent 循环状态(plus run 输入)确定性投影出 一份中立的 Turn 列表:

  • 输入 → 一条 user turn;
  • 每个记录过的动作(call/sub)→ 一对 turns:assistant(决策,带 tool_calls 形状)+ tool(结果);
  • 内容不内联——正文躲在 content_ref(blob 引用)后面,水合(解析 blob)、token 预算、摘要都推迟到 invoke_reasoner 效果里做,因为那里可以在工作流历史之外碰 IO (同 docstring)。

为什么派生? 存储的转录会和确定性重放打架:同一份历史重放必须产出同一份转录。 派生则天然一致——转录就是状态的纯函数(julep/transcript.py:134 的 "Deterministic given the same state, policy, and run input — safe to compute in workflow code")。

4.2 作用域:四档,永远显式

哪些轮次进入转录由叶子的 ContextPolicy(julep/ir.py:129)决定,scope 四档 (julep/kinds.py:106):

ContextScope模型看到
none / local只有直接输入(不进转录;TRANSCRIPT_SCOPES 只含后两档,julep/transcript.py:50)
summary被省略轮次的运行摘要(SUMMARY)
whole_session全部轮次(WHOLE_SESSION)

ambient context 是被禁止的——上下文消耗必须声明,于是"这个 reasoner 到底看了多少 历史"是可审计的(投影里看得到)。还有一个结构性纪律:par 里两个 whole_session 读 会被校验器降级为串行(julep/ir.py:129 的 ContextPolicy docstring)——两个全量读者 并发必然在转录上竞速。

4.3 硬预算:省略是显式的,绝不静默

split_to_budget(julep/transcript.py:197)把转录按 token 预算切成 (elided, kept):

  • 保新裁旧:从最新往回装,装不下的旧轮次进 elided;
  • 硬上限:连最新一轮都超预算时全部省略——"没有静默超限";
  • 原子省略:tool 结果轮和它的 assistant 调用轮一起省,绝不让 provider 看见孤儿 tool 结果(同函数 docstring);
  • 显式标记:被省略的轮数以 elision_marker(:224)变成一条 system turn—— "模型被告知,绝不被静默欺骗";SUMMARY 作用域则用 summary_turn(:229)给出 "早前轮次的摘要"。

token 计数器是注入的(TokenCounter 协议);默认 approx_token_count(:187)是 ~4 字符/token 的启发式,真计数是 LlmCaller 的事(同 docstring)。

4.4 另一条上下文防线:Joined 防火墙

子流程(SubStep)对父流程只回一份 SummaryPolicy(julep/kinds.py:115: result_only/compressed_trace/full_child_ref)——子流程看了什么、记了多少, 不泄漏进父流程的投影与转录。这是"上下文纪律"在流程边界的镜像。


5. 三层全景图

怎么读这张图: 左边是数据结构(谁存什么),右边是每轮模型调用时的组装流水线。

结构层 每轮组装(invoke_reasoner 效果内)
────────────────────── ──────────────────────────────────────
Session(LOOP body+双通道)
│ 每轮:AgentState.trace ──▶ transcript_for(确定性投影)
│ │ Turn 列表(正文躲在 content_ref 后)
│ ▼
│ split_to_budget(硬 token 上限,保新裁旧)
│ │ elided + elision_marker / summary_turn
│ ▼
SessionStore(cursor 乐观并发) 水合 content_ref(blob → 真内容)
+ BlobStore(内容寻址) ▼
喂给 LlmCaller(真 token 计数在此)

部件一句话职责:

部件干什么在哪
Session / @sessionLOOP 边界声明julep/session.py:75/:187
Channel类型化的输入/输出端口julep/session.py:43
drive_session本地会话驱动(事件流)julep/session.py:823
transcript_for状态 → 转录的纯投影julep/transcript.py:128
split_to_budget硬 token 预算切分julep/transcript.py:197
elision_marker/summary_turn显式省略/摘要标记julep/transcript.py:224/:229
SessionStoredurable 会话状态(cursor 乐观并发)julep/execution/session_store.py:60
SessionWorkflowdurable 会话(轮边界截断)julep/execution/harness.py:1759
ContextPolicy/ContextScope上下文作用域声明julep/ir.py:129/julep/kinds.py:106
SubContract/SummaryPolicy子流程上下文防火墙julep/ir.py:284/julep/kinds.py:115

6. 巧妙之处(可借鉴的技术)

  • 转录是投影不是表。 "对话历史"不是一等存储,而是循环状态的纯函数——重放确定性 白送,新旧版本的状态都能投影出一致的转录。见 julep/transcript.py:128

  • 内容躲在引用后面。 转录只装 content_ref,水合推迟到效果层——工作流历史不被大 正文撑爆,和第 02 章的投影 value_ref、codec claim check 是同一个思想的三次应用。 见 julep/transcript.py:1 docstring。

  • 省略永远显式。 超预算不是悄悄截断:system turn 直接说"N 条早前轮次被省略"。 模型知道自己失忆,比自以为全知更安全。见 julep/transcript.py:224

  • 作用域是叶子属性,不是全局旋钮。 每个 reasoner 声明自己看多少上下文; 全量读者并发被结构性降级。上下文消耗成了可审计、可预算的资源。 见 julep/kinds.py:106julep/ir.py:129

  • 互斥委托给引擎不变量。 会话存储不自建锁:session_id == workflow_id 的 1:1 映射 把互斥交给 Temporal 的 one-running-execution-per-id。见 julep/execution/harness.py:2498


7. 边界与局限(诚实)

  • 没有内建 RAG。 向量/全文/混合检索、文档切块、embedding——统统没有。知识检索 走外部 MCP 工具(第 04 章),框架不评价其质量。
  • 转录只覆盖 call/sub 动作。 transcript_for 只投影 decision ∈ (call, sub) 的 轮次(julep/transcript.py:143 起的过滤)——纯变换步不进转录。
  • 默认 token 计数是启发式。 approx_token_count 约 4 字符/token;要精确计数得在 worker 侧注入真 tokenizer(julep/transcript.py:187)。
  • 会话存储的 durable 路径是 Temporal-only。 DBOS 后端明确"session-store (use_session_store/state_cursor) path is Temporal-only"(julep/execution/dbos_backend.py:1 docstring)。
  • v1 迁移没有故事。 旧的 entries/docs 数据与 v3 会话模型之间没有任何转换工具 (README.md:236:无迁移路径)。

8. 横向对比(同 shelf 兄弟项目)

  • 记忆分层: 多数 agent 框架把"历史 + 摘要 + 检索"揉进一个 memory 接口;v3 反其道, 对话内上下文做成确定性投影 + 硬预算(本章),长期知识外包给工具面(第 04 章)。 好处是执行内核零存储依赖,代价是没有开箱即用的知识库。
  • 上下文管理: "每叶子声明作用域 + 并发全量读者降级"是少见的好纪律;常见做法是全局 上下文窗口参数,谁读了多少说不清。
  • 想看本 shelf 其它子库如何实现记忆/检索,见各自子库 doc 与总库对应原理章节。

9. 代码地图(导航索引)

主题文件路径符号名
会话声明julep/session.py:75Session / session(:187) / scan(:161)
通道julep/session.py:43Channel / recv / emit / drain
会话事件julep/session.py:85SessionEvent / SessionHandle(:133)
本地驱动julep/session.py:823drive_session
LOOP 的 IR 形态julep/ir.py:425Node(Op.LOOP 分支见 julep/execution/interpreter.py:295)
通道引用julep/ir.py:105ChannelRef
保留收/发工具julep/ir.py:37RECV_TOOL / EMIT_TOOL(:41)
recv/emit 组合子julep/derived.py:197recv / emit(:210)
转录投影julep/transcript.py:128transcript_for / Turn(:31)
token 预算切分julep/transcript.py:197split_to_budget / approx_token_count(:187)
省略/摘要标记julep/transcript.py:224elision_marker / summary_turn(:229)
转录作用域julep/transcript.py:50TRANSCRIPT_SCOPES
上下文策略julep/ir.py:129ContextPolicy / ContextScope(julep/kinds.py:106)
子流程摘要策略julep/kinds.py:115SummaryPolicy / SubContract(julep/ir.py:284)
会话存储协议julep/execution/session_store.py:60SessionStore / CursorConflict(:56) / value_fingerprint(:47)
会话状态 activityjulep/execution/effects.py:670loadState / commitState(:676) / loadValue/commitValue
durable 会话julep/execution/harness.py:1759SessionWorkflow / _should_continue_as_new(:2141)
会话示例examples/session_demo.py——

相关章节:会话在执行引擎里的位置见 02;reasoner 声明见 01;这些机制部署后如何被观测见 06