你的 AI 为什么总在你没批准的时候动手?
从「一次授权」到「单轮写租约」:给长任务 Agent 加一道会过期的写权限。
项目:Agent-LongRun-Framework v3 仓库:github.com/lss48655835…
我用 AI 写代码最怕的不是它不会写,而是它太积极。
你花二十分钟跟它对需求,刚说「先看一下现在的实现」,它已经打开编辑器,把认证层重构了。你问它为什么,它还很委屈:「我以为你希望我直接修好。」
你骂它不听话,但问题往往不在模型。问题在于你的 Agent 根本没有「对话」和「执行」的边界。
后来我换了个方式管 Agent,把它当成产品经理来对接:
Agent 的运作,就像跟产品经理对接。 对话态(只读):与 Agent 对齐颗粒度——澄清需求、探讨方案; 执行态(可写):审核通过方案后的任务下发——闭门写代码,绝不瞎发散。
这两句话说起来很顺,落到代码里却要回答一个很具体的问题:
「方案已经批准」这件事,到底由谁记住?又由谁在什么时候收回?
如果答案只是「写在 prompt 里」,那它就不是一条工程约束。模型心情好的时候会遵守,上下文一长、工具一多、宿主一换,它就可能忘。真正要解决的问题是:让「没批准就不能写」变成一个不依赖模型自觉的拒绝点。
这就是 Agent-LongRun-Framework v3 的起点。它不是又一个 Agent 框架,不调用 LLM,也没有编排 DSL。它只做一层很薄的运行时契约:
- 一个 7 态任务状态机;
- 一个按对话轮次过期的写租约;
- 一个绑定到项目根目录的记忆路由。
下面是我为什么这么设计,以及哪些地方还没有做好。
一、三类故障,本质上是同一个问题
长任务 Agent 的故障,表面看是「模型不听话」,落到工程上通常是三件事没有契约。
1. 断点说「已保存」,其实没有保存
这是一种很隐蔽的假回执。
Agent 在上下文快满的时候输出一段断点:
[LongRun Checkpoint]
阶段:接口适配
下一步:接入第二个供应商
用户看到这段话,以为进度已经存下来了,放心关掉窗口。结果进程一退,聊天记录成了唯一副本。下次恢复时才发现,记忆库里根本没有这条断点。
问题不在于「有没有写断点」,而在于写的动作和说的动作谁先谁后。如果先打印、后写库,中间任何一个异常都会制造一份看起来存在、实际不存在的进度。
我的处理很硬:断点必须先成功写入记忆,才允许输出。写失败就不输出,转而落到本地的 pending_checkpoint.json,让下一次恢复知道「这里有一笔没完成的保存」。断点是回执,不是日志;回执不能先写「已保存」再办事。
2. 你只批准了一次,它写了一整轮
这是更常见、也更危险的一类。
v2 里的 enable_writes() 是一个持久开关:调用一次,任务就进入 writable,后面所有轮次都能写。用户的真实意图往往只是「这一个动作可以」,但代码把它理解成了「这个任务从此都可以」。
于是出现这样的对话:
用户:把 A 文件的超时改成 30 秒。
Agent:好的,已修改 A。
用户:顺便看一下 B 为什么报错。
Agent:我顺手把 B、C、D 也重构了。
从 Agent 的视角,它没有违背任何指令:写权限还在,任务还在执行态。它只是把「一次批准」误当成了「持续授权」。
一个批准应该覆盖多少工作量?答案不应该是「直到任务结束」,而应该是「直到下一次需要用户确认的边界」。这就是单轮写租约要解决的问题。
3. 状态在 A,记忆在 B
第三类故障在恢复时才暴露。
状态文件在 ~/.longrun/,记忆库在另一个全局目录;或者 LongRunAgent 指向项目 A,MCPMemoryBackend 却用了默认的项目 B。resume() 读到的是 A 的 next_step,再去 B 的历史里找上下文,最后拼出一个谁都不认识的进度。
这类问题不会在 happy path 上出现。它只会在换机器、切项目、删缓存、开新进程的时候出现,而那几个时刻恰好是你最需要断点可靠的时候。
所以 v3 把「项目根」变成唯一的绑定:
<项目根>/.longrun/state.json # 热状态
<项目根>/memory.db # 冷记忆
状态和记忆必须指向同一个 root。resume() 可以读不到记忆,但不能读到另一个项目的记忆。
二、Prompt 不是契约
上面的三类问题,都可以在 prompt 里写一句「不要这么做」。但 prompt 是建议,不是拒绝。
模型可以说「好的」,然后继续做;上下文可以被截断;不同宿主对工具调用的包装也不一样。更重要的是,prompt 无法给你一个可测试的失败路径。你没法在 CI 里断言「模型这次一定会拒绝」。
我不是说 prompt 没用。它适合表达意图、风格、优先级,适合告诉 Agent「我们想要什么」。但下面这些东西应该由代码保证:
- 什么状态可以流转到什么状态;
- 写权限在什么时候失效;
- 断点和记忆谁先写;
- 恢复时读哪个项目的数据。
这些是运行时契约,不是提示词技巧。
三、四条不变量
v3 的设计可以压缩成四条不变量。每一条都有对应的代码和测试,不靠自觉。
I1:先查记忆,再建任务
start_task() 的第一件事不是创建 TaskState,而是检索项目记忆:
# 铁律 1:任务开始先查记忆。
terms = _normalize_keywords(keywords) or extract_keywords(task_desc)
memories = self._search_for_task(terms)
# 检索完成后,才创建状态并落盘。
state = TaskState(task_id=..., description=task_desc)
state.transition(TaskStatus.IN_PROGRESS, "start_task")
self.store.save(state)
测试 test_start_task_searches_before_creating_state 直接断言第一次后端调用必须是 search,而不是断言「最后有没有查到东西」。
查不到记忆可以是空的,但不能不查。因为一旦允许「先建任务、后补记忆」,就会出现一个没有历史上下文的新任务,后续所有决策都建立在错误的空白上。
I2:先写记忆,再输出断点
checkpoint() 不是「拼一段文本然后打印」。它的顺序是:
- 在状态副本上修改,避免失败时污染内存里的真实状态;
- 收回当前写租约;
- 渲染断点文本和机器标记;
- 调用
save_memory(); - 只有第 4 步成功,才写
state.json并返回断点。
关键代码如下:
try:
record = self.memory.save(
type="session",
title=title,
content=content,
keywords=f"longrun,checkpoint,{working.task_id}",
)
except Exception as exc:
fallback = self._write_fallback(working, kind, reason, summary, content)
self._revoke_write_lease(state, "checkpoint write failed")
raise CheckpointWriteError(
"记忆写入失败,断点未输出(规则:必须先写记忆,再输出断点)。"
f"已暂存到 {fallback},恢复记忆服务后重新调用 checkpoint()。"
) from exc
这里有两个细节值得说。
第一,失败时改的是 state,不是 working。因为 working 只是内存里的副本,还没有落盘;真正需要收回的是当前活着的任务状态。
第二,断点里有一行机器可读标记:
<!-- longrun-state: {"task_id":"T_...","status":"paused","access_mode":"read_only",...} -->
恢复时不需要让模型从自然语言里猜「现在是 paused 还是 in_progress」,直接解析这行 JSON 重建状态。自然语言给人看,机器标记给程序看,两者都在同一份断点里。
这里还涉及一个跨存储的顺序问题:state.json 在本地,记忆在 SQLite,两者不可能放进同一个数据库事务。那就必须选一个「谁是回执的凭据」。我的选择是以记忆写入成功为准,本地状态只在成功之后推进;失败时本地保留旧状态,另外落一份 pending 文件。这样最坏的情况是「本地看起来还没保存,但记忆里已经有」,下次可以幂等补齐;而不是「本地看起来保存了,记忆里什么都没有」。
测试 test_checkpoint_saves_before_returning 和 test_checkpoint_write_failure_blocks_output 覆盖了正常路径和失败路径。后者会断言:写入失败时,checkpoint() 抛出 CheckpointWriteError,并且不返回断点文本。
I3:写权限只覆盖一轮
这是 v3 的核心变化,也是我花时间最多的地方。
v2 的写权限是一个布尔开关:read_only 或 writable。v3 把它拆成两个字段:
@dataclass
class TaskState:
...
# 对话轮次。每次 begin_turn / resume / recover_from_memory 递增。
turn_index: int = 0
# 写租约绑定的轮次。只有 write_lease_turn == turn_index 时 writable 才有效。
write_lease_turn: int | None = None
enable_writes() 做的不只是把 access_mode 改成 writable,它还把租约绑定到当前轮次:
state.access_mode = AccessMode.WRITABLE
state.write_lease_turn = state.turn_index
而 require_writable() 会同时检查两件事:
if state.access_mode is not AccessMode.WRITABLE:
raise WriteGateViolation(
f"当前是只读/对话态,拒绝执行「{action}」。"
"先完成调研并调用 enable_writes(原因) 显式解锁。"
)
if state.write_lease_turn != state.turn_index:
self._revoke_write_lease(state, "stale write lease")
raise WriteGateViolation(
f"「{action}」被拒绝:写权限已过期"
f"(授权轮次 {state.write_lease_turn},当前轮次 {state.turn_index})。"
"请在当前轮次重新调用 enable_writes(原因)。"
)
使用起来是这样:
agent.enable_writes("方案确认")
agent.require_writable("修改 adapters/xometry.py") # OK,第 1 轮
agent.begin_turn("用户发来下一条消息")
agent.require_writable("继续修改 adapters/xometry.py")
# WriteGateViolation:授权轮次 1,当前轮次 2
为什么不能只把 access_mode 设回 read_only?
因为收回点不止一个:begin_turn()、end_turn()、checkpoint()、resume()、recover_from_memory() 都应该收回写权限。如果只有一个布尔值,任何一个路径忘了改,权限就会泄漏。更麻烦的是,从磁盘加载一个旧状态时,access_mode 可能仍然是 writable,但那一轮早就结束了。
两个字段把「授权给哪一轮」这件事显式化。即使 access_mode 仍然是 writable,只要 write_lease_turn 对不上 turn_index,require_writable() 也会拒绝。测试 test_stale_lease_is_rejected_even_if_mode_is_writable 专门覆盖这个场景。
这也是我把它叫「租约」而不是「锁」的原因。锁解决互斥:同一时刻只允许一个持有者。租约解决授权范围:谁批准的、批准到哪一轮、什么时候收回。这里没有并发竞争问题,真正的问题是「批准的有效期」。如果把它实现成锁,代码会围绕加锁和解锁打转;实现成租约,代码围绕轮次和收回点打转,后者更贴近 Agent 的实际对话模型。
v2 的旧状态在加载时会被自动降级为 read_only,测试 test_legacy_writable_state_is_revoked_on_load 锁住了这个兼容行为。升级不应该把旧的 writable 带进新版本。
还有一个容易被忽略的细节:checkpoint() 本身也会收回租约,而且是在写记忆之前收回。断点意味着这一轮执行结束,恢复出来的一定是 read_only。resume() 和 recover_from_memory() 各自会开始一个新的对话轮次,所以恢复后也需要重新授权。
这里有一个宿主责任:框架看不到「用户发来一条新消息」这件事。宿主必须在每条新消息开始时调用 begin_turn()。这是当前设计的边界,也是后面「已知限制」里的第一条。
I4:状态和记忆绑定同一个项目根
LongRunAgent(root=...) 同时决定两件事:
- 热状态写到
<root>/.longrun/state.json; - 默认的
MCPMemoryBackend以--project_path <root>启动,记忆写到<root>/memory.db。
这意味着 resume() 读到的状态和记忆永远属于同一个项目。换项目时,换 root 就够了,不需要分别配置状态目录和记忆目录。
测试 test_default_backend_binds_to_project_root 和 test_mcp_backend_passes_project_path 分别验证了 Python 后端和 MCP 子进程的绑定。后者会检查子进程参数里确实包含 --project_path <root>,而不是只检查构造函数的入参。
记忆本身是项目级的白盒结构:SQLite 索引 + raw_data/ 原文,冷热分离。它不是向量数据库,检索目前是关键词启发式;这一点我在限制里会明确写。
四、7 态状态机:先分清「停」的三种含义
写权限解决「这一轮准不准动手」,状态机解决「现在该谈还是该做」。
v3 有 7 个状态:
pending
in_progress
paused
blocked
waiting_verification
completed
failed
它们不是把 5 个状态拆细,而是把「停」的原因分开:
paused:主动保存进度,等待恢复;blocked:被外部条件卡住,等依赖解除;waiting_verification:实现完成,等人工验收。
这三种状态都能回到 in_progress,但恢复时要做的事不一样。把它们混成一个 paused,Agent 恢复后就不知道是该继续写、等外部服务,还是等用户点头。
状态流转是白名单:
TRANSITIONS = {
TaskStatus.PENDING: {TaskStatus.IN_PROGRESS},
TaskStatus.IN_PROGRESS: {
TaskStatus.PAUSED,
TaskStatus.BLOCKED,
TaskStatus.WAITING_VERIFICATION,
TaskStatus.COMPLETED,
TaskStatus.FAILED,
},
TaskStatus.PAUSED: {TaskStatus.IN_PROGRESS, TaskStatus.FAILED},
TaskStatus.BLOCKED: {TaskStatus.IN_PROGRESS, TaskStatus.FAILED},
TaskStatus.WAITING_VERIFICATION: {
TaskStatus.IN_PROGRESS,
TaskStatus.COMPLETED,
TaskStatus.FAILED,
},
TaskStatus.COMPLETED: set(),
TaskStatus.FAILED: set(),
}
不在表里的跳转一律抛 TransitionError;completed 和 failed 是终态,没有出口。
还有一个很小的决定:状态文件损坏时直接抛 StateCorruptError,不当成「没有任务」。因为静默当成空状态,下一次 start_task() 就会覆盖真实进度。宁可停下来,也不要把用户的进度吃掉。
五、跑一遍:租约到底会不会过期
上面都是设计。真正让人放心的是一段可以复现的输出。
在干净环境里安装两个包:
python -m pip install "agent-memory-router @ git+https://github.com/lss486558354-ship-it/agent-memory-router-v2.git"
python -m pip install "agent-longrun-framework @ git+https://github.com/lss486558354-ship-it/Agent-LongRun-Framework-v3.git"
agent-longrun demo
demo 会在临时目录跑完整链路,不调用 LLM,也不需要 API key。关键输出如下:
[1] start_task: read-only by default
gate: 当前是只读/对话态,拒绝执行「demo write」。
[2] enable_writes: lease for this turn only
gate: writable; turn: 1
[3] checkpoint: save memory before emitting the resume token
[LongRun Checkpoint]
状态: paused · 读写门禁: read_only · 对话轮次: 1
[4] new agent: resume
gate after resume: read_only; turn: 2
gate: 当前是只读/对话态,拒绝执行「write after resume」。
expected: the write lease does not survive resume
[5] re-authorize for the new turn, then finish
status: completed; memory: S_0002
OK: checkpoint -> resume -> single-turn write lease -> finish
最值得看的是第 4 步:一个新的 Agent 进程恢复任务后,第一次写操作被拒绝了。不是因为它忘了 prompt,而是因为 resume() 已经把 turn_index 推进到 2,而租约还停留在 1。
第 5 步重新授权后,才能继续写,然后 finish。
这段输出也是 README 里 GIF 的内容来源。你可以自己跑一遍,不用信我。
六、它不是什么:和 LangGraph / Temporal / Mem0 / hooks 的边界
这个项目很容易被误解成「又一个 Agent 框架」。先把边界说清楚:
| 项目 | 它解决什么 | 与本项目的关系 |
|---|---|---|
| LangGraph | 图编排、图内 checkpointer | 本项目管写权限生命周期、记忆写入顺序、项目绑定;可以包在 LangGraph 外面 |
| Temporal | 分布式 durable execution | 本项目是单机、文件 + SQLite 的轻量契约,不需要集群 |
| Mem0 / 向量库 | 语义长期记忆 | 本项目是项目级 SQLite + 原文冷热分离,检索目前是关键词;记忆后端可以替换,契约不变 |
| Claude Code hooks | 宿主级工具拦截 | 本项目是后端无关的 Python API + MCP,不绑定某个宿主 |
有人会问:为什么不直接用数据库事务?
因为事务能保证单个数据库内的原子性,却定义不了「一轮对话」是什么,也替代不了跨存储的写入顺序。本项目是本地 JSON + SQLite,目标是单机 Agent;需要分布式 durable execution 的场景,应该用 Temporal 这类系统,而不是给这层薄契约加一个伪分布式事务。
它不是编排框架,不调用 LLM,没有 DSL。它只回答三个问题:
- 这个任务现在是什么状态,下一步流转合法吗?
- 这一轮允许写文件吗?
- 进程死了以后,真正的进度在哪里?
如果你已经有编排层,它应该是一个可以叠在上面的约束层,而不是替代品。
七、已知限制
这部分我主动写,不藏在 README 角落。
begin_turn()需要宿主在每条新用户消息开始时调用。框架看不到消息边界,这是当前设计的硬限制。resume()/recover_from_memory()已经各包含一次begin_turn,不要对同一条消息重复调用。- 每次 MCP 调用都会拉起一个子进程;高频检索场景需要长连接或本地后端。
LocalMemoryBackend的项目根是模块级全局配置,同一进程不能并行切换项目。- 关键词提取是启发式的,没有语义向量。
- 状态文件没有加锁,同一个
.longrun/不支持并发写。 - 目前是同步 API,异步宿主需要线程池包装。
- Windows 控制台需要
PYTHONIOENCODING=utf-8,示例里已经做了重配置。 - 当前版本是 v0.3.0,适合个人和小团队的长任务;生产使用需要按自己的并发和持久化要求评估。
框架本身有 52 个 pytest,配套的记忆路由器有 47 个;CI 覆盖 Python 3.10–3.12。测试不是「跑过了」的装饰,上面每一条不变量都有对应的行为测试。
八、最后
回到开头那个问题:AI 为什么总在你没批准的时候动手?
因为它不知道「批准」是有范围的。
Prompt 可以告诉它「先问我」,但只有运行时契约能保证:你没批准的时候,写操作真的会被拒绝;你批准了一次,这次批准真的只覆盖一轮;断点说保存了,记忆里就真的有一条记录。
我把这套东西压缩成一句话:
对话态负责对齐,执行态负责任务下发,租约负责让「下发」过期。
如果你也在做长任务 Agent,欢迎直接跑 demo,或者看看代码里的测试。最想被拍砖的是单轮租约这个设计:它是不是过度设计?在你自己的场景里,哪些操作应该跨轮授权,哪些必须每轮重新确认?
一条命令体验:
python -m pip install "agent-memory-router @ git+https://github.com/lss486558354-ship-it/agent-memory-router-v2.git"
python -m pip install "agent-longrun-framework @ git+https://github.com/lss486558354-ship-it/Agent-LongRun-Framework-v3.git"
agent-longrun demo
如果这个边界设计对你有用,欢迎提 issue 告诉我哪里不对。