W7-D5:从 Session、Context Compaction、Tracer 理解 Agent Harness 工程化
这一天开始,我感觉自己学的东西开始从“LangGraph API”慢慢变成了真正的工程代码。
前面学 LangGraph 的时候,我更关注:
一个节点怎么写? 一个 State 怎么传? 一个
Send怎么并发? 一个interrupt怎么暂停?
但 Agent 真正跑起来以后,很快会遇到另一类问题:
- 程序重启了,之前的对话怎么办?
- 对话越来越长,模型上下文装不下怎么办?
- Agent 明明执行了很多步骤,但出了问题以后根本不知道哪里慢、哪里炸?
- 这些能力如果都塞进 Agent 主逻辑,代码最后会变成什么样?
所以 Day5 做的,其实不是“再学三个 Python 类”。
而是在学习一个更重要的问题:
怎么把一个 Agent 从“能跑”,逐渐变成“可以长期运行、可以维护、可以排查问题”的系统。
Day5 的三个模块分别是:
| 模块 | 类比 | 解决的问题 |
|---|---|---|
| Session Store | 游戏存档 | 重启以后状态不丢 |
| Context Compaction | 行李超重打包 | 长任务不会因为上下文越来越大而爆掉 |
| Tracer | 外卖配送轨迹 | 出问题以后知道谁调用了谁、花了多久、哪里失败 |
而整个 Harness 的整体设计里,它们分别属于第 3、4、5 个模块。
一、先不要看代码:我先建立整个系统的骨架
先把它画出来。
| |
这里有一个非常重要的思想:
这三个模块并不是 Agent 的“业务逻辑”
例如:
| |
这是业务逻辑。
而:
| |
属于基础设施能力。
也就是:
Agent 负责“做什么”,Harness 负责“让它能够稳定地做”。
这个思路非常重要。
因为如果这些东西全部写进 Agent:
| |
最后 Agent 会越来越胖。
以后想:
- 换存储方式
- 换压缩策略
- 换日志系统
- 删除 Trace
- 增加监控
都会直接碰业务代码。
这就是工程化最核心的问题之一:
把容易变化的基础设施能力,从核心业务逻辑里拆出去。
二、第一个模块:Session Store
2.1 Session 到底是什么?
先不要管 Python。
假设你正在和 Agent 聊天。
| |
这里 Agent 为什么知道“昨天那个格式”?
因为它需要历史状态。
如果程序运行到一半:
| |
内存中的:
| |
没了。
那么下一次启动:
| |
这就是 Session Store 要解决的问题。
可以直接把它理解成:
游戏存档点。
代码注释本身就是这么定义的:打到一半关机,下次读档还在原来的位置。
三、SessionStore 的第一个工程问题:存在哪里?
代码:
| |
这里最重要的不是 Python 语法。
而是这个设计:
| |
和:
| |
代表两种后端。
内存模式
| |
数据:
| |
程序一退出:
| |
适合:
| |
文件模式
| |
数据:
| |
程序重启:
| |
这就是第一个非常典型的工程化思维:
调用方只关心“我能不能保存”,不应该关心你底层到底是内存、文件还是数据库。
以后:
| |
如果上层接口没变:
| |
调用方就不用跟着一起重写。
这就是:
抽象接口,隔离实现。
四、JSONL 是什么?
这一段第一次看到的时候容易懵。
JSON:
| |
JSONL 就是:
一行一个 JSON。
例如:
| |
它有一个特点:
特别适合追加。
因为新增一轮消息时,不需要重新把整个文件改一遍,只需要:
| |
这也就对应代码里的:
| |
这里的:
| |
就是 append:
追加。
五、为什么这里选择 append-only?
append-only 的意思就是:
历史记录只增加,不修改。
例如:
| |
不会写成:
| |
而是:
| |
一直往后追加。
为什么?
因为它对崩溃更友好。
假设正在写:
| |
程序突然:
| |
最坏情况:
| |
但是:
| |
还在。
而如果你每次都:
| |
那么中途崩溃,就可能直接把整个文件破坏。
所以这个设计本质上是在做:
把“修改旧数据”的风险,变成“追加新数据”的风险。
这是非常典型的工程化取舍。
六、SessionStore 怎么恢复数据?
看这个函数:
| |
它做的事情其实非常简单:
| |
核心代码:
| |
这里的:
| |
可以先理解成:
如果这个 key 没有,就先创建一个空列表。
例如第一次看到:
| |
相当于:
| |
然后:
| |
所以最终:
| |
七、为什么读取坏行不直接报错?
这里是一个很重要的工程思维。
代码:
| |
如果文件中间有一行:
| |
它不是完整 JSON。
普通程序可能:
| |
但这里的设计是:
| |
也就是:
局部损坏,不要扩大成整个系统不可用。
这是非常典型的容错设计。
八、append 里还有一个容易被忽略的东西:fsync
代码:
| |
第一次看到特别容易觉得:
不就是写文件吗?
其实这里是在解决:
“我写成功了”和“数据真的已经落到持久化介质”是不是一回事?
简单理解:
| |
flush() 更接近:
把 Python/文件对象里的缓冲往操作系统推。
fsync() 则是:
请求操作系统把这个文件的数据同步到持久化存储。
所以这里的思想是:
宁愿每次写慢一点,也希望 Session 的持久化可靠性更高。
代码注释也明确写了:这是以每次写更慢为代价来换取更强的持久化保障。
九、为什么要保存 ts / thread_id / seq / messages / meta?
最终写入的结构:
| |
可以把它理解成:
| |
这里值得注意:
| |
不是直接把原列表对象塞进去,而是复制一层。
同理:
| |
也复制了一层。
这背后的工程意图可以理解为:
Session 保存的是“这一时刻的快照”,而不是把调用方未来还会修改的容器直接绑进来。
十、Session Store 的公开 API 为什么这么设计?
代码最后有:
| |
可以画成:
| |
这里有一个很典型的代码组织方式:
底层能力少,上层 API 组合。
比如:
| |
它没有重新实现“怎么读文件”。
而是直接使用:
| |
所以:
| |
是一层一层建立起来的。
这就是为什么代码会比“每个函数自己重新读取文件”更容易维护。
十一、delete 为什么这么麻烦?
前面说:
append-only。
那为什么删除还要重新写文件?
因为你不能从一个文本文件中间:
| |
所以做法变成:
| |
代码:
| |
重点不是 .tmp。
重点是:
| |
它的目标是让外部尽量只看到两种状态:
| |
或者:
| |
而不是:
| |
所以这里体现的是另一个工程化关键词:
原子性。
十二、SessionStore 的整体流程
把这一整个类压缩成一张图:
| |
现在再看 SessionStore,就不容易迷路了。
十三、第二个模块:Context Compaction
Session 有了以后,新的问题出现了。
假设 Agent 执行了:
| |
Session 说:
没问题,我全部给你保存。
但是模型说:
等一下,我上下文不是无限的。
于是:
| |
并不等于:
| |
这就是 Context Compaction。
十四、Compaction 到底在干什么?
代码注释用了一个很好理解的比喻:
| |
假设:
| |
但是飞机:
| |
最粗暴的方法:
| |
问题:
你以后可能还需要那 30 件。
另一种方法:
| |
然后:
| |
这就是压缩。
代码文件也明确把它定义成:
压缩是“投影”,不是“销毁”。原始消息仍然保存在 SessionStore 中。
十五、这里有一个特别重要的工程决策
问题:
压缩后的消息,要不要直接覆盖 Session?
答案:
不要。
采用:
| |
也就是:
| |
而不是:
| |
为什么?
因为:
| |
如果原始数据没了:
| |
以后就回不来了。
如果原始数据一直保存:
| |
随时可以重新计算。
这其实就是一个非常重要的工程模式:
原始数据和派生数据分开。
可以把它理解成:
| |
这也是这三个文件里非常重要的一条设计思想。
十六、needs_compaction() 为什么这么简单?
代码:
| |
现在我们只按:
| |
判断。
比如:
| |
那么:
| |
但是这里也留下了一个非常重要的工程问题:
消息条数 ≠ Token 数。
例如:
| |
只有很少内容。
但另一条:
| |
依然只算:
| |
所以以后更真实的生产设计,触发条件可能应该是:
| |
这里代码自己的注释其实也已经意识到这个问题。
这就是一个非常好的工程学习方式:
不是看到现有代码就结束,而是继续问:这个设计在哪种情况下会失效?
十七、为什么 maybe_compact() 不直接返回原消息?
代码:
| |
这一点特别值得学。
很多初学者会写:
| |
但这里选择:
| |
为什么?
因为调用方可以明确区分:
| |
这其实是在设计一个更清晰的函数契约:
| |
这比:
| |
更容易让上层判断发生了什么。
十八、system 消息为什么单独拿出来?
代码:
| |
这里是在做:
| |
因为 system 通常承担:
| |
如果把它当普通历史消息一起删掉,整个 Agent 的行为边界可能变化。
所以工程上经常会看到:
| |
分层处理。
十九、整个压缩核心其实就是这几行
| |
翻译成人话:
| |
可以画成:
| |
二十、这里出现了一个真正的坑:tool 消息不能随便切
这是这份代码里最值得我记住的一点之一。
Agent 的消息不只是:
| |
还可能有:
| |
它们实际上是一个逻辑整体。
例如:
| |
如果你压缩的时候变成:
| |
但是:
| |
被删掉了。
那模型拿到的就是一个:
孤儿 tool 结果。
所以:
| |
它的思想非常简单:
如果切点正好落在 tool 结果上,就往前退。
也就是:
| |
这就是工程化和“只是会写列表切片”的区别。
普通程序员可能想:
| |
工程代码会继续问:
这样切有没有违反上层协议?
这就是非常重要的思维。
二十一、_summarize() 为什么接受一个 summarize 参数?
代码:
| |
这里其实是在做:
依赖注入。
意思是:
| |
所以以后可以传:
| |
也可以:
| |
或者:
| |
这样:
| |
不需要知道:
| |
都是什么。
这就是:
让模块依赖“接口/能力”,而不是依赖具体实现。
二十二、为什么默认摘要不是 LLM?
代码里面还提供了一个无 LLM 的版本:
| |
最后得到类似:
| |
这其实是一个很好的工程设计。
为什么?
因为:
| |
和:
| |
被分开了。
这样测试的时候:
| |
一样可以验证:
| |
这是非常典型的:
把外部依赖隔离出去,先测试自己的逻辑。
二十三、这里有一个我在最新版代码里发现的问题
当前代码:
| |
从前面的变量命名和最后返回结果来看,代码意图明显是:
| |
而当前写的是:
| |
这意味着:
如果没有传 summarize:
| |
那么:
| |
即使刚刚已经生成了摘要。
如果传了 summarize:
那么:
| |
保存的是:
| |
而不是:
| |
所以这不是语法错误,而是一个非常典型的:
“代码能运行,但是状态语义错了”的 Bug。
这其实比语法错误更值得学习。
因为工程里最危险的问题之一就是:
| |
二十四、第三个模块:Tracer
Session 解决:
| |
Compaction 解决:
| |
但是现在还有一个问题:
Agent 出问题以后,我怎么知道到底发生了什么?
例如:
| |
最终:
| |
你只知道:
| |
但不知道:
| |
Tracer 就是干这个的。
代码把它比喻成:
外卖配送轨迹。你能看到“接单 → 到店 → 配送 → 送达”,Tracer 则给 Agent 的每一步记录开始、耗时、成功与否。
二十五、Tracer 和 Audit 有什么区别?
这两个特别容易混。
可以这样记:
| |
也就是:
| |
所以:
Audit 更偏安全、审计。
Trace 更偏性能、排障、可观测性。
二十六、Tracer 最核心的概念:Span
一个 span 可以先理解成:
一次函数/步骤执行的记录。
例如:
| |
意思:
| |
如果 agent 又调用了:
| |
那么:
| |
意思:
| |
于是就形成:
| |
这就是 Trace 树。
二十七、这里第一次遇到装饰器
你之前没有专门学过装饰器,所以这里单独补。
最简单的装饰器其实就是:
| |
然后:
| |
它实际上相当于:
| |
所以:
| |
这就是装饰器。
二十八、为什么 Tracer 特别适合用装饰器?
因为我们不想每个节点都写:
| |
例如:
| |
如果每个函数都手写 Trace:
| |
太重复了。
所以变成:
| |
业务函数不用关心 Trace。
这就是:
横切关注点。
业务逻辑:
| |
Trace:
| |
两者分开。
二十九、把当前 Tracer 的装饰器拆开
代码核心:
| |
这里有三层:
| |
第一次看到非常绕。
实际上可以这样理解:
第一层
| |
是:
我准备制作一个“装饰器”。
第二层
| |
是真正接收到:
原来的那个函数。
第三层
| |
是真正以后执行的函数。
所以:
| |
相当于:
| |
三十、*a, **kw 是什么?
这一句:
| |
意思是:
不管原函数接收什么参数,我都先接住。
例如原函数:
| |
可以。
原函数:
| |
也可以。
原函数:
| |
也可以。
所以包装器不需要知道每个业务函数到底有几个参数。
这里:
| |
接收:
| |
这里:
| |
接收:
| |
然后:
| |
再把它原样交给原函数。
三十一、functools.wraps 为什么要写?
代码:
| |
装饰器一个常见问题是:
| |
可能被 wrapper 覆盖掉。
wraps 可以帮助 wrapper 保留原函数的元信息。
对现在学习阶段来说,记一句即可:
写装饰器时,通常把
functools.wraps一起写上。
它属于装饰器的工程规范,而不仅仅是为了“能运行”。
三十二、Tracer 为什么先取 parent?
代码:
| |
意思:
看看“现在是不是已经有一个 Trace 在执行”。
如果没有:
| |
那么当前 span 是根节点。
如果有:
| |
那么:
| |
当前这个 span 就知道:
我是谁的孩子。
三十三、这就是 contextvars
代码:
| |
它保存的是:
当前执行上下文中的 span。
然后:
| |
就是:
把当前 span 设置成我。
之后内部再调用一个被 Trace 包住的函数:
| |
就能读到:
当前外层是谁。
于是:
| |
自然就串起来了。
三十四、为什么不用一个普通的全局变量?
如果简单写:
| |
单线程可能看不出问题。
但真实 Agent 系统可能是:
| |
如果大家都共用:
| |
就可能出现:
| |
于是父子关系串线。
contextvars 就是专门为这种:
上下文相关、并发场景下需要隔离的数据
设计的。
所以这里实际上不是“为了炫一个 Python API”。
而是:
因为系统未来会并发,所以现在不使用一个简单但不安全的全局变量。
这就是设计和 API 的区别。
三十五、Tracer 最关键的一段:try / except / finally
代码:
| |
这一段值得仔细学。
三十六、为什么 return out 不能漏?
假设:
| |
Tracer 包装以后:
| |
如果后面没有:
| |
那么:
| |
得到的就是:
| |
原函数明明返回了:
| |
结果被装饰器吃掉了。
这就是为什么注释里专门写:
| |
因为:
一个装饰器应该在增加能力的同时保持原函数的行为。
这是工程上非常重要的原则:
非侵入式增强。
三十七、为什么异常要 raise?
代码:
| |
很多初学者会觉得:
都捕获了,为什么还要再抛?
因为 Trace 的职责是:
| |
而不是:
| |
比如业务代码:
| |
Tracer 应该:
| |
所以:
| |
职责不能混在一起。
三十八、为什么日志放到 finally?
因为:
| |
无论:
| |
最终都会执行。
所以:
| |
之后:
| |
仍然会先完成 Trace 记录,再真正返回。
这样:
| |
两条路径都能留下记录。
这就是为什么:
| |
这里其实已经不是简单语法题了。
它是在实现一个工程保证:
无论业务成功还是失败,都尽量记录一次执行结果。
三十九、为什么 _current.reset(token) 也必须做?
之前:
| |
相当于:
| |
那么结束以后:
| |
就是:
| |
否则下一次执行的时候:
| |
可能还残留。
所以这是另一种工程思维:
设置了上下文,就必须负责恢复上下文。
很像:
| |
四十、Tracer 怎么得到父子树?
每条 span 都保存:
| |
所以:
| |
其实不是提前存了一棵树。
而是:
每个节点只保存“我的父亲是谁”。
最后:
| |
把:
| |
重新组织成:
| |
这叫:
扁平记录,树形重建。
这是一种很常见的数据设计。
因为写入的时候:
| |
比较简单。
真正展示的时候:
| |
比较灵活。
四十一、为什么 total_ms() 只统计顶层 span?
代码:
| |
为什么不把所有 span 都加起来?
假设:
| |
如果全加:
| |
但实际上:
| |
更接近:
| |
因为:
| |
已经包含在 agent 的执行时间里面。
所以:
统计指标必须先定义语义,再写代码。
这也是工程化非常重要的一点。
不是:
| |
就代表:
| |
四十二、把三个模块连起来
现在把整个流程画出来。
| |
所以现在可以把三者记成一句话:
Session 管“数据有没有”,Compaction 管“这次给模型看多少”,Tracer 管“这次到底发生了什么”。
四十三、真正的工程化思维:不是“我会不会写这个类”
我觉得 Day5 最应该学的,其实不是:
| |
而是下面这个思考顺序。
第一步:先找失败场景
不要问:
我要写一个 Session 类。
先问:
如果 Agent 重启,会发生什么?
于是得到:
| |
不要问:
我要写一个 Compactor。
先问:
如果对话越来越长,会发生什么?
于是得到:
| |
不要问:
我要写一个 Tracer。
先问:
Agent 出问题以后,我怎么排查?
于是得到:
| |
四十四、第二步:给每个模块划边界
Session
负责:
| |
不负责:
| |
Compactor
负责:
| |
不负责:
| |
Tracer
负责:
| |
不负责:
| |
边界越清楚:
| |
四十五、第三步:定义“不变量”
工程代码真正重要的,不只是“正常情况下怎么跑”。
而是:
什么事情无论如何都不能发生?
这三个模块里,其实有很多这样的“不变量”。
Session
| |
Compaction
| |
Tracer
| |
Tracer
| |
Compaction
| |
一旦开始用:
“这个系统必须永远保证什么?”
去看代码。
你就开始进入工程思维了。
四十六、第四步:再考虑异常
继续拿三个模块举例。
Session
如果文件坏了一行:
| |
Delete
如果重写过程中挂了:
| |
Tracer
如果日志落盘失败:
| |
为什么?
因为:
日志系统不能反过来杀死主业务。
Tracer
如果业务抛异常:
| |
为什么?
因为:
Trace 不能吞掉业务错误。
这些都是非常典型的:
失败隔离。
四十七、第五步:再考虑“以后会不会变”
这就是为什么这里大量使用:
| |
例如:
Session
今天:
| |
以后:
| |
Compactor
今天:
| |
以后:
| |
Tracer
今天:
| |
以后:
| |
好的工程代码不是:
“今天刚好能跑。”
而是:
在合理范围内,为未来变化预留替换点。
四十八、这三个文件里我最值得记住的 8 个工程设计
1. 原始数据和派生数据分开
| |
2. append-only 优先
| |
比:
| |
更容易处理崩溃。
3. 局部失败不要扩大
| |
不应该变成:
| |
4. 基础设施不能改变业务语义
Tracer:
| |
不能:
| |
5. 业务异常不能被基础设施吞掉
| |
必须保留。
6. 依赖注入
| |
意味着:
| |
7. 原子替换
| |
避免中间态破坏真实文件。
8. 先定义指标含义再计算
| |
没有简单地:
| |
而是只加:
| |
因为“总耗时”有明确的业务语义。
四十九、三个文件的最新版代码
下面代码保持你上传的三个最新版文件内容不变,方便以后复习时直接对照。
session.py
| |
compact.py
| |
trace.py
| |
五十、最后重新理解一下:“工程化”到底是什么?
以前我可能会把工程化理解成:
| |
现在理解得更准确一点。
工程化并不是:
把代码写得更复杂。
而是:
面对真实运行环境中的失败、变化、并发、维护问题,提前设计边界和保护机制。
比如:
| |
你会发现:
工程代码很多时候不是在写“正常情况”。
而是在写:
| |
这才是今天真正应该带走的东西。
五十一、Day5 我现在真正学到的,不是三个类
可以最后把今天压缩成这一张图:
| |
真正的工程思维则是:
| |
所以以后再看到一个陌生的工程文件,我不能只问:
“这个函数是干嘛的?”
还应该继续问:
“它防的是哪个问题?”
再继续:
“如果删掉这段,会坏在哪里?”
再继续:
“为什么不使用更简单的写法?”
最后:
“这个模块和其他模块之间,边界在哪里?”
这应该就是我这一阶段真正要练的东西。
五十二、复习时我只需要记住这 12 句话
- Session 是存档,不是 Agent 业务逻辑。
- 原始历史尽量保存,压缩属于派生视图。
- append-only 的价值是降低修改历史数据带来的崩溃风险。
- 坏一条数据,不应该让整个系统一起死。
- Compaction 不是删除历史,而是减少这次送给模型的数据。
- 消息条数不等于 Token 数。
- tool call 和 tool result 不能随便拆。
- 装饰器本质是“用一个 wrapper 包住原函数”。
- Tracer 必须透传原函数返回值。
- 记录异常和处理异常是两回事,所以要
raise。 contextvars是为了让嵌套、并发执行下的上下文不串线。- 工程化真正关注的是:失败、边界、变化和可维护性。