开始前,会这些就够了
- 能读懂 Python 类、字典与递归调用
- 先读受控的多 Agent 规划
- 了解资料时效与获取时间的区别
行程助手刚查完周六的活动,用户补了一句:“改成周日,预算还是三十五。”如果助手沿用旧活动表,最后行程会混进周六项目;如果把所有工作一律推倒,连只负责调整语气的步骤也要重新来。要决定重做哪些步骤,先得知道每个结果到底用过什么。
本项目从需求输入一直做到行程草稿,活动目录、查询逻辑和时钟都放在本地。你会得到一份可运行的 Python 程序,观察日期、预算和文风变化各自触发哪些计算,再模拟旧查询晚到与来源过期。它研究已有成果何时可以继续使用;已有规划课程中的任务拆分与关键路径只是起点。
把例子带到自己的电脑上
下载完整练习包
需要 Python 3.10 或更新版本;制作时实测 3.14.0。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。
下载 ZIP · 5 个文件包里有什么
- replanning_workflow.py:完整示例
- README.md:环境、范围与独立练习
- observed.stdout.txt:制作时的观察记录
- verification.json:对应源码的验证范围
- SHA256SUMS.txt:文件内容摘要
在这个文件夹打开 PowerShell,逐行运行
python --version
python -B replanning_workflow.py解压后在含示例文件的文件夹打开 PowerShell,按命令运行。先预测结果,再与当次输出对照。 离线固定数据与应用规则,不代表真实模型、完整协议或服务联调。
找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。
先把“行程”缩成一个能验收的小应用
应用有四项输入:出行日 day、单项活动价格上限 budget、草稿文风 style、活动目录 catalog。目录内有周六公园、周六博物馆、周日博物馆三条记录。budget 筛选每个活动的价格,不计算整日总花费;这个约定先写清,避免把一个简单筛选器误当成预算优化器。
工作分四步。events 按日期找活动;route 给这些候选活动标记步行路线;select 按价格上限筛选;draft 把选中的活动、路线和文风合成一个字典。路线是固定演示值,没有地图计算,也没有预约、支付或消息发送。完整流程的输入、转换与结果都能在一个文件里检查。
每份输入与产物带有自己的 revision。这个数字只在同一项内容的历史里递增,events 的第 2 版不能与 budget 的第 2 版比较新旧。给结果记录来源版本,比只给整个对话放一个“最新版本”标志更细,也为后续复用留下依据。
画出“用过谁”,才知道变化会传到哪里
本例明确声明:events 依赖 day 和 catalog;route 依赖 events;select 依赖 events 和 budget;draft 依赖 route、select 和 style。改预算只会使 select 与 draft 失效;改文风只影响 draft;改日期则要从 events 开始重做其下游。依赖不是执行顺序的别名,它描述结果形成时真正读取了什么。
程序从被修改的输入出发,反复找到依赖它的节点,再找到这些节点的后继。把最终找到的旧产物移出可用区后,ensure 从目标 draft 往前补齐缺少的依赖。仍然可用的节点直接返回,所以复用是检查后的结果,不是因为某个节点“看起来大概没变”。
Bazel 的 Skyframe 文档解释了相似的增量计算依据:计算中的读取如果没有进入依赖图,增量结果就可能错误。本项目借用这条思路自行写小程序,没有调用 Bazel。若以后路线实际还读了天气,就必须把天气纳入依赖;仅在提示词里提醒“考虑天气”不足以保护复用。
开工时留底,交结果时再对一遍
begin 为一次计算生成 Ticket:步骤名、尝试编号、直接依赖的版本清单、这些输入当时的内容副本,以及最早到期时间。compute 只读票据里的副本,因此旧查询在运行期间不会偷偷读到新日期的一半与旧目录的一半。样例用 deepcopy 隔开快照,公开字典仍属于受信任的教学内部结构。
finish 先确认这还是该步骤当前的尝试,再取得现在有效的依赖版本。如果开工时是 day 第 1 版,返回时已变成第 2 版,旧结果就得到 stale_dependencies,不能盖住周日结果。同一步骤启动新尝试后,老尝试即使更晚返回,也会因编号不符被拒绝。重复提交同一张票据同样不能再写一次。
如果期间只改了预算,events 的 day 与 catalog 都没变,它的迟到结果仍可以接收。这里故意不用“整个任务版本必须完全相同”作唯一判断,否则无关变更也会丢掉有效工作。接收以后,下游仍按自身依赖重新检查;局部接收不意味着最终行程已经全部可用。
需求没改,目录也可能已经过期
目录的有效期设为演示时钟 t=10,含起点、不含终点。events 继承目录的期限,后续产物继承其依赖中最早的期限。于是到了 t=10,即便日期和预算完全没动,也不能继续取出旧草稿。当查询运行到一半越过期限,finish 同样拒绝它,而不是只在开工时查一次。
ensure 遇到过期的根输入会抛出 SourceExpired,交给来源重新核验流程。示例随后显式演示一次成功核验:目录事实未变,但授予新的期限 t=20。这会形成新目录版本并保守重算下游。代码不会把当前时间改成“已核验时间”,也不会仅因重试就延长来源寿命。
十个时间单位只是实验参数。实际来源是否承诺有效期、允许多久缓存、缺少时效信息怎么办,需要另外制定规则。W3C PROV 的使用、派生、失效概念能帮助描述产物关系,却没有替行程助手规定刷新周期;本例的期限传播是自己的应用策略。
跑完整流程,再看实际执行了哪几步
先独立预测三次重算列表:把价格上限从 50 改成 35、把 brief 改成 detailed、把 sat 改成 sun。然后运行下面的完整程序。下载包内的同名文件与代码块相同,可直接保存;没有模型密钥,也不需要数据库。最终草稿由 ensure("draft").data 取得,断言检查其活动确实属于周日。
日志中的 computed 是实际进入 begin 的步骤。第一轮四步都执行;第二轮只筛选与拼草稿;第三轮只拼草稿。脚本还检查历史草稿副本仍保留周六内容,新草稿只剩周日博物馆;这把“没有浪费计算”和“结果没有混用旧需求”分成了两个可观察条件。
展开收起代码 · 小实验
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
"""Offline read-only workflow; no LLM, network, purchase or real clock."""
from copy import deepcopy
from dataclasses import dataclass
class SourceExpired(Exception):
pass
class NotReady(Exception):
pass
@dataclass(frozen=True)
class Value:
revision: int
data: object
expires_at: int | None = None
dependencies: tuple = ()
@dataclass(frozen=True)
class Ticket:
node: str
attempt: int
dependencies: tuple
values: dict
expires_at: int | None
class Planner:
# Every input read by compute() is declared here. Logic is fixed in this run.
dependencies = {
"events": ("day", "catalog"),
"route": ("events",),
"select": ("events", "budget"),
"draft": ("route", "select", "style"),
}
def __init__(self):
self.now = 0
self.inputs = {
"day": Value(1, "sat"), "budget": Value(1, 50),
"style": Value(1, "brief"),
"catalog": Value(1, [
{"id": "park-sat", "day": "sat", "cost": 20},
{"id": "museum-sat", "day": "sat", "cost": 45},
{"id": "museum-sun", "day": "sun", "cost": 30},
], expires_at=10),
}
self.artifacts, self.pending, self.revisions = {}, {}, {}
self.next_attempt = 0
self.executed = []
def view(self, name):
value = self.inputs.get(name) if name in self.inputs else self.artifacts.get(name)
if value is None:
raise NotReady(name)
if value.expires_at is not None and self.now >= value.expires_at:
raise SourceExpired(name)
if name in self.dependencies:
current = tuple((dep, self.view(dep).revision) for dep in self.dependencies[name])
if current != value.dependencies:
raise NotReady(name)
return value
def affected(self, changed):
found = set(changed)
while True:
previous = len(found)
for node, deps in self.dependencies.items():
if any(dep in found for dep in deps):
found.add(node)
if len(found) == previous:
return [node for node in self.dependencies if node in found]
def invalidate(self, changed):
nodes = self.affected(changed)
for node in nodes:
self.artifacts.pop(node, None)
# Pending work can still finish. Accept/reject is checked at finish().
return nodes
def update(self, name, data, expires_at=None):
if name not in self.inputs:
raise KeyError(name)
old = self.inputs[name]
if old.data == data and old.expires_at == expires_at:
return []
self.inputs[name] = Value(old.revision + 1, deepcopy(data), expires_at)
return self.invalidate([name])
def begin(self, node):
values = {dep: self.view(dep) for dep in self.dependencies[node]}
signature = tuple((dep, item.revision) for dep, item in values.items())
deadlines = [item.expires_at for item in values.values() if item.expires_at is not None]
self.next_attempt += 1
ticket = Ticket(node, self.next_attempt, signature,
{dep: deepcopy(item.data) for dep, item in values.items()},
min(deadlines) if deadlines else None)
self.pending[node] = ticket.attempt
self.executed.append(node)
return ticket
def finish(self, ticket, data):
if self.pending.get(ticket.node) != ticket.attempt:
return "superseded_attempt"
try:
signature = tuple((dep, self.view(dep).revision)
for dep in self.dependencies[ticket.node])
except SourceExpired:
return "expired_dependencies"
except NotReady:
return "stale_dependencies"
if signature != ticket.dependencies:
return "stale_dependencies"
if ticket.expires_at is not None and self.now >= ticket.expires_at:
return "expired_dependencies"
revision = self.revisions.get(ticket.node, 0) + 1
self.revisions[ticket.node] = revision
self.invalidate([ticket.node])
self.artifacts[ticket.node] = Value(revision, deepcopy(data),
ticket.expires_at, ticket.dependencies)
del self.pending[ticket.node]
return "committed"
@staticmethod
def compute(ticket):
value = ticket.values
if ticket.node == "events":
return [item for item in value["catalog"] if item["day"] == value["day"]]
if ticket.node == "route":
return {item["id"]: "walk" for item in value["events"]}
if ticket.node == "select":
return [item for item in value["events"] if item["cost"] <= value["budget"]]
if ticket.node == "draft":
return {"style": value["style"], "items": [
{"id": item["id"], "cost": item["cost"], "route": value["route"][item["id"]]}
for item in value["select"]]}
raise KeyError(ticket.node)
def ensure(self, name):
try:
return self.view(name)
except (NotReady, SourceExpired):
if name in self.inputs:
raise # A tool/source refresh is required; never invent freshness.
for dependency in self.dependencies[name]:
self.ensure(dependency)
ticket = self.begin(name)
result = self.finish(ticket, self.compute(ticket))
if result != "committed":
raise NotReady(result)
return self.view(name)
def main():
planner = Planner()
first = deepcopy(planner.ensure("draft").data)
assert len(first["items"]) == 2
print("initial computed:", ",".join(planner.executed))
planner.executed.clear()
changed = planner.update("budget", 35)
assert changed == ["select", "draft"]
planner.ensure("draft")
assert planner.executed == ["select", "draft"]
print("budget recomputed:", ",".join(planner.executed))
planner.executed.clear()
planner.update("style", "detailed")
planner.ensure("draft")
assert planner.executed == ["draft"]
print("style recomputed:", ",".join(planner.executed))
assert planner.update("style", "detailed") == []
old_ticket = planner.begin("events")
planner.update("day", "sun")
rejected = planner.finish(old_ticket, planner.compute(old_ticket))
assert rejected == "stale_dependencies"
print("late old-day result:", rejected)
current = planner.ensure("draft").data
assert [row["id"] for row in current["items"]] == ["museum-sun"]
assert [row["id"] for row in first["items"]] == ["park-sat", "museum-sat"]
still_valid = planner.begin("events")
planner.update("budget", 40) # This input is not an events dependency.
accepted = planner.finish(still_valid, planner.compute(still_valid))
assert accepted == "committed"
print("late result after unrelated change:", accepted)
older, newer = planner.begin("events"), planner.begin("events")
assert planner.finish(older, planner.compute(older)) == "superseded_attempt"
assert planner.finish(newer, planner.compute(newer)) == "committed"
assert planner.finish(newer, planner.compute(newer)) == "superseded_attempt"
expires_while_running = planner.begin("events")
planner.now = 10
assert planner.finish(expires_while_running, planner.compute(expires_while_running)) == "expired_dependencies"
try:
planner.ensure("draft")
except SourceExpired:
print("at t=10: source refresh required")
else:
raise AssertionError("expired source must not be silently refreshed")
planner.executed.clear()
# A scripted successful source recheck; same facts, a new accepted expiry.
planner.update("catalog", planner.inputs["catalog"].data, expires_at=20)
planner.ensure("draft")
assert planner.executed == ["events", "route", "select", "draft"]
print("after source refresh:", ",".join(planner.executed))
print("PASS: dependency snapshots, selective rebuild, late results, source expiry")
if __name__ == "__main__":
main()
怎样运行:保存为 replanning_workflow.py,用 Python 3.10 或更新版本运行:python replanning_workflow.py。只用标准库,不联网、不调用模型,也不安装软件包。 2026-10-13 已用 3.14.0 执行这份示例并通过自带断言;下方为当次输出。离线固定数据与应用规则,不代表真实模型、完整协议或服务联调。
运行后对照结果
initial computed: events,route,select,draft
budget recomputed: select,draft
style recomputed: draft
late old-day result: stale_dependencies
late result after unrelated change: committed
at t=10: source refresh required
after source refresh: events,route,select,draft
PASS: dependency snapshots, selective rebuild, late results, source expiry为一次重做留下一条可解释的理由
一个可读的执行记录至少应回答:哪个输入从哪版变到哪版、哪些产物受影响、哪个步骤真正重跑、哪份结果因为什么被拒绝。本例返回不同状态并输出执行清单,方便沿着依赖检查。若只打印“重新规划成功”,错误复用与多跑一遍都很难从日志中发现。
保留旧结果可用于解释当时为何给出某个建议,但它不应继续出现在当前可用产物区。样例用独立副本保留第一份草稿,用 artifacts 保存当前产物。真实应用可以存历史记录与来源引用;其中可能含个人行程,保留范围和访问权限也要由应用决定。
DVC 的运行文档把命令、依赖和参数变化都纳入阶段是否需要重跑的判断。对应到 Agent,除用户参数外,提示词、计算逻辑、模型版本与工具行为也可能改变结果。本文件固定了所有计算逻辑,只演示数据依赖;扩展复用到跨部署任务时,必须把这些变化也加入识别条件。
能解释一次运行,还没有解决分布式并发
脚本按单线程顺序调用 begin、update 和 finish,用这种排列模拟“先开工、需求变化、旧结果返回”。它验证该顺序下的判定规则,不证明多个进程同时写入时仍然安全。生产系统需要让检查当前版本与写入产物成为受保护的同一次提交,否则两者中间仍可能插入需求变更。
图必须完整且无环,本例是手工固定的四节点图。没有实现动态发现依赖、循环检测、持久化恢复、任务取消或历史任务清理。拒绝旧结果也不等于取消远端工作:计算可能已经花掉资源;如果远端操作会真的下单,阻止旧结果入库更不能撤销订单。本项目因此只做只读草稿。
相同输入内容与相同期限再次提交不会触发变化,但某一步重新计算后即使产出相同内容,本例仍递增版本、使下游失效。这是便于审计的保守选择。读 Skyframe 的 change pruning 时,应看出它还讨论了输出相同可恢复下游的进一步优化,不能把那项能力归给当前代码。
验收时交出结果,也交出复用的依据
完成本项目后,交付物应包含脚本、一次完整运行日志、四项输入的依赖图,以及一张变更与重算步骤对照表。表中再列两种无需用户改口也会拒绝结果的情况:尝试已被替代、来源已经到期。请从程序读取判定依据,不要只抄最终 PASS 字样。
阅读原始资料时,可以先看 Skyframe 的 Evaluation 与 Incrementality,再对照 DVC 的 Caching Stages,最后用 PROV 的 Usage、Derivation、Invalidation 给产物关系起准确名字。三份资料解释的是各自体系;本项目的 Agent 工作流需要由自己的断言和应用规则负责。
记住这三点
- 安全复用需要完整依赖、对应版本与来源时效同时成立。
- 需求改动沿实际依赖传播,无关变更不必使所有工作重跑。
- 开工快照与提交前复核能区分有效迟到结果、旧需求结果和被替代的尝试。
别把例子用过头
- 单项价格筛选没有计算整日总预算;步行路线也只是固定示例。
- 单线程判定模型未验证多进程原子提交、远端取消或有副作用的业务操作。
- 依赖图漏掉提示词、逻辑、模型或外部输入时,复用仍可能错误;本例只固定演示数据变化。
自己试一小步
如果新增“下雨时只选室内活动”,你能让天气变化只重做真正受影响的工作吗?
- 自行决定天气影响哪些步骤,先画图并给出四种变更的预计重算列表。
- 扩展目录与输入,再安排天气查询晚到、天气来源恰好过期两种顺序。
- 保留原来的断言并添加自己的反例;另写一段真实并发提交时需要保证什么。
做到这些,就算完成
- 新增逻辑的每次读取是否进入依赖清单?
- 无关变更能否复用,同时不会接收使用过期天气的结果?
- 日志能否说明重做与拒绝原因,且未把离线顺序测试说成并发系统证明?
查原始资料
想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。
- Skyframe:依赖跟踪与增量计算 (新标签页)↗
阅读 Data model、Evaluation、Incrementality;支持依赖须通过图记录、从变化向下游失效及相同输出剪枝的概念。本文没有实现其全部能力。
- DVC:运行流水线与阶段缓存 (新标签页)↗
阅读依赖顺序与 Caching Stages;支持阶段的命令、依赖和参数共同影响重跑判断。未执行 DVC。
- PROV-DM:使用、派生与失效 (新标签页)↗
阅读 5.1.4 Usage、5.1.8 Invalidation、5.2.1 Derivation;支持描述来源与产物关系。规范不规定本例的缓存算法或有效期政策。