Agent 与环境交互:下一步,要看世界发生了什么
“请帮我打印一张测试页。”
助手检查了远端服务,请你装上纸,然后收到一句“好的,马上”。现在,它可以宣布完成了吗?
还不行。纸可能还在抽屉里,打印队列可能堵着,测试页也还没出来。即使它说得头头是道,任务仍停留在原地。
在第 34 章中,我们已经搭过“选择动作—执行工具—读取结果”的循环。这里继续追问:当变化需要等待,一部分操作掌握在别人手里,还有多个执行者同时工作时,这个循环怎样才能持续推进?
我们会让一个真实小模型操作可运行的打印机模拟环境,再枚举两个执行者争用同一名额的情况。模型没有完成任务;这些失败能帮助我们看清,困难究竟出在读取反馈、选择行动,还是环境本身。
前置内容还包括工具调用与工作流、应用评估与可靠性和长上下文与记忆。资料核查截至 2026 年 10 月 20 日。文中选取五篇研究连接交互接口、用户协作、长任务和可验证环境;本书实验不复现这些论文的榜单成绩。
1. 世界的状态,和助手知道的事
先把打印任务拆成三个容易混淆的东西。
| 对象 | 在打印任务里是什么 |
|---|---|
| 环境状态 | 服务是否启用,纸是否装好,已经打印了几页 |
| 观察结果 | 工具这一次告诉助手的内容 |
| 交互历史 | 助手已经做过的动作,以及收到过的结果 |
环境可能已经变了,助手却还不知道;助手也可能以为某件事已经完成,环境实际并没有变化。“我请求用户装纸”属于交互历史,“纸已装好”属于环境状态。两者之间还隔着用户的行动。
用 表示第 步前的状态, 表示助手的动作, 表示这一步期间用户完成的操作。环境按照规则 更新:
这里先用确定性的模拟规则说明问题。真实设备还可能出现随机故障,需要把 换成描述各种下一状态概率的转移模型。
工具通常只返回状态的一部分。记返回的观察为 ,历史为 ,就有:
是观察规则。例如,查远端设置能够看到服务和队列,却不能直接看见用户手里的纸。助手只能根据手头的历史选择下一步。这叫作部分可观察:信息有缺口,行动既能改变环境,也能帮助补上缺口。
这也是 ReAct 关注的连接:模型交替进行语言层面的分析和实际行动,让外部观察参与后续决策。原论文研究了问答与交互任务,不能把“用了一个循环”就称为完整复现。下面的小模型只选动作字母,没有生成自由形式的分析文本。ReAct 原文第 2 节
2. 搭一台会认真记账的打印机
五个条件,一页也不能多
我们的环境有五个布尔值:远端服务已启用、队列已清空、设备已开机、有纸、线已接好。全部为真时,打印动作才能成功。
远端服务和队列可以由助手直接修改;开机、装纸、接线必须请求模拟用户完成。这个“用户”是一段固定规则程序,会立即完成、延迟完成或拒绝指定操作,并不是另一个语言模型,也不模拟真实人的表达习惯。
可选动作只有十一种。字母是代码中的动作编号,不需要把它们记成缩写。
| 动作 | 含义 | 直接结果 |
|---|---|---|
| A | 查看远端 | 返回服务与队列状态 |
| B | 询问设备情况 | 返回开机、纸张与接线状态 |
| C、D | 启用服务、清空队列 | 立即修改相应远端状态 |
| E、F、G | 请求开机、装纸、接线 | 已完成、已受理、等待中或被拒绝 |
| H | 打印一页 | 实际页数加一,或返回未就绪 |
| I | 结束任务 | 停止交互,交给独立规则判断成功 |
| J | 等待 | 让交互时钟前进一步 |
| K | 转交处理 | 在必要操作被拒绝后结束未解决的任务 |
每个动作都会让时钟前进一步。一步开始时,先兑现已经到期的用户操作,再执行当前动作。这里的“一步”是模拟时序,不是一秒钟。
环境不会替模型屏蔽不合时宜的动作。缺纸时仍可以选 H,打印两页后仍可以选 I;这样才能看见真实错误,而不是由动作菜单悄悄替模型做好决定。
正常任务的通过条件是:
表示“并且”。如果用户确实无法完成必要操作,则改为要求:该操作已经被拒绝、没有打印、最终选择转交。直接转交一个本可完成的任务,或者一句“完成了”之后没有实际打印,都不通过。
这套规则刻意把“结束交互”和“完成目标”分开。它也很有限:不检查纸上内容或打印质量,不能当成真实打印设备的验收系统。
先固定八种起点
实验开始前,我们写下八个案例,随后全部运行。没有看完结果再留下容易成功的起点。
| 案例 | 开始时的问题 | 用户响应 |
|---|---|---|
| ready | 全部就绪 | 即时 |
| server | 服务关闭、队列未清空 | 即时 |
| paper | 缺纸 | 即时 |
| device | 未开机、未接线 | 即时 |
| mixed | 服务关闭、缺纸 | 延迟 2 步 |
| all-now | 五项均未就绪 | 即时 |
| all-delayed | 五项均未就绪 | 延迟 3 步 |
| paper-unavailable | 缺纸 | 无法装纸,拒绝请求 |
案例名和初始状态只交给环境与评分程序,不放进模型提示。所有案例给助手的目标都一样:打印恰好一页并结束;必要用户操作被拒绝时才转交。
一次正确的等待
先运行一份手写策略:查看、修复、等待完成回执、打印、结束。它只使用已经收到的观察,不偷看隐藏状态。它的作用是检查任务是否可完成,以及时间规则是否符合预期;8/8 是这段程序的结果,不是小模型的成绩。
以 mixed 为例,第 4 步请求装纸,环境返回 accepted,预约在时钟 6 完成。第 5 步等待时纸还没装好,第 6 步才收到完成事件。接着打印,最后结束。
图 1:从程序实际执行记录绘制。已受理只表示操作进入等待队列;完成事件才把“有纸”改为真。
这份策略在八个案例中分别用了 4、6、5、6、8、9、12、4 步,共 54 步。最后一个案例通过的是“确认无法装纸后转交”,并没有打印成功。
3. 让真实模型来选择下一步
这次到底运行了什么
模型使用 Qwen2.5-0.5B-Instruct,固定修订为 7ae557604adf67be50417f59c2c2f167def9a775。在 CPU 上用 float32、4 线程、eager attention 运行,Python 3.12.12、PyTorch 2.14.1、Transformers 4.57.3。没有训练或更新权重。
提示和工具说明使用英文;本页用中文解释其含义,实验不评价中文指令能力。每次输入包括动作说明、任务目标和完整可见历史。程序计算模型对下一个词元的分数,只在 A~K 这十一种单词元字母中选最高分,不采样。设动作 的分数为 ,则:
的意思是“取使分数最大的那个动作”。这里每一步确实经过了语言模型前向计算,但输出接口经过人为简化:不是自由生成 JSON,也不是模型原生的工具调用接口。
每个案例最多执行 16 个动作,输入加本次输出上限为 4096 词元;若超出上限就停止,不截断历史。本次实际输入为 276~514 词元,没有触及上下文上限。
我们固定比较两种反馈:
| 模式 | 模型收到什么 |
|---|---|
| full | 时钟、状态码、具体状态字段、用户完成事件 |
| receipt | 时钟与状态码,隐藏具体字段和完成事件 |
receipt 仍能收到 printed、refused 等状态,不能称为“没有反馈”。它改变了信息的详细程度;两种模式的提示也各有一句相应说明。因此,这是两个完整交互配置的对照。
200 次选择,没有一张测试页
运行结果如下:
| 方案 | 通过案例 | 总动作数 |
|---|---|---|
| 手写观察策略,full | 8/8 | 54 |
| 小模型,full | 0/8 | 72 |
| 小模型,receipt | 0/8 | 128 |
full 的八个案例都走出了相同序列:连续七次 C,然后 J,最后 K。也就是不断启用服务,等一步,再转交。它没有观察纸张、队列或接线,没有请求用户操作,自然也没有依据认定用户无法配合。
receipt 的八个案例同样走出相同序列:C、A、八次 C、六次 J,最后耗尽 16 步。它查过一次远端,但只得到简短状态码,随后又回到启用与等待。
图 2:每组中的八个案例动作完全一致。方框宽度用于排版,不表示耗时;次数写在字母旁。
为什么不同初始状态会产生一样的路径?因为它们还没被模型区分出来。反复执行 C,只能反复确认服务已启用;查不到纸张,也修不好接线。在这些轨迹中,两种反馈模式各自返回的可见内容始终没有区分八个案例,确定性选择便沿着同一路径继续。
甚至 ready 已经全部就绪,模型也没选 H。于是,这批失败发生在最基本的行动推进上,还没有走到复杂的延迟协作。我们不能根据两个 0/8 就说“详细反馈没用”,也不能把这个小模型的结果推广到所有 Agent。
记录还保存了十一种合法字母在原始词表分布中的总概率,实际为约 99.35%~99.69%。这说明本次失败并非主要表现为“模型几乎只想输出菜单外的文字”。但合法动作的概率高,完全不代表动作正确。第一步最偏好的 C,在十一项中的归一化概率也只有约 16.8%。
这些是八个固定案例、每种配置各一次贪心运行,不是八次独立抽取的真实用户任务。失败过程值得分析,数字的适用范围也就到这里。
4. 任务长了,为什么容易越走越偏
十六步并不算真正的长期任务。但这台小打印机已经展示了两个会被长任务放大的问题:旧观察不断重复,目标却没有推进;“应该做什么”与“已经做到什么”混在了一起。
假设一个任务有 个关键环节。在前面都成功的条件下,每个环节的成功概率都恰好是 ,而且任何一次失败都无法挽回,那么整条链成功的概率是:
若 ,十个环节约为 59.9%,二十个环节约为 35.8%。这是说明累积风险的算例,不是从本书轨迹估计的成功率。若只知道每一步单独看起来“95% 正确”,还需要独立性等额外条件,才能直接相乘。
真实任务往往允许重试、撤销或换方案,因此不必接受“一错到底”。更有用的计划会把阶段结束的证据写清楚:
| 阶段 | 怎样知道已经完成 |
|---|---|
| 检查条件 | 已取得当前远端与设备状态 |
| 修复条件 | 所需变更已完成,而不只是已受理 |
| 执行目标 | 实际打印计数变成 1 |
| 结束 | 已确认页数,选择结束动作 |
如果任务恢复时只保存了“接下来打印”,却丢了“装纸请求尚在等待中”,下一次行动就可能建立在错误前提上。可以保留当前目标、已核实状态、未完成操作及其时间,再在关键写入之前重新读取会变化的状态。长上下文与记忆中的事件时间与信息更新,在这里直接影响行动。
检查也要花成本。每做一步就把所有状态全查一遍,可能浪费大量调用;一直相信开头那次查询,又可能使用过时信息。更合理的检查位置取决于哪些信息容易变化,以及弄错之后需要付出多少代价。
还有一个容易误判的动作:等待。图 1 中的等待有明确对象——一个尚未完成的装纸请求;模型轨迹中的连续等待却没有任何待完成用户操作。在后一种情况下,延长步数上限未必能带来进展。系统可以记录连续无变化的动作,在达到约定阈值后重新规划或停止,再把这一规则本身纳入评估。
交互接口也会影响这种行为。SWE-agent 研究了文件查看、搜索、编辑反馈和历史管理:让模型看到相关片段、编辑后的状态以及错误,比把未经整理的全部终端内容塞进去更值得认真设计。其接口消融在特定模型和 SWE-bench Lite 设置下进行,不意味着存在适合所有工具的统一历史长度。SWE-agent 原文第 3 节与第 5.1 节
5. 多一个执行者,多了哪些事情
能控制的部分不同,沟通才有实际内容
打印助手能修改远端配置,用户能装纸、接线。两者都在改变同一个任务,却看到不同信息、拥有不同操作。只要把用户当成“提供一句回复的人”,就很容易漏掉正在发生的状态变化。
τ²-Bench 把这一点做成了可执行的双控制环境:Agent 和模拟用户各有工具,用户行为与环境状态相连。论文比较了正常协作、由 Agent 直接控制全部工具,以及提供正确操作计划等条件,用来拆分行动与沟通困难。后两种给了额外能力或信息,是诊断用的对照,不能直接当成真实客服的部署方案。τ²-Bench 原文第 3、4 节
我们的打印环境只保留了最小的权限分工和延迟规则,没有自然语言协商、歧义、情绪或复杂用户行为。它能检验“已受理之后何时改变状态”,不能检验“这句话是否能让真实用户理解并完成操作”。
两个人都看见空位,为什么会超额
再看另一个小实验:某个时段只有一个预约名额,执行者 A 和 B 都想预约。每个人先读一次,再写一次。
如果双方都在任何写入之前读到“空闲”,之后只相信这个旧读数,就可能各自写入成功,最后出现两个预约。两个人在各自的局部视角里都觉得操作合理,共享状态却已经违反容量限制。
一种办法是在读取时同时取得版本号。提交时,把“检查版本仍相同”和“写入并增加版本”放在一个不可分割的操作中:
是状态版本,不是预约数量。假设双方都读到版本 0,A 成功提交后版本变成 1,B 再带着 0 提交就会得到冲突。B 需要重新读取或另选时段。
图 3:程序中“检查并写入”是不可分割的一步。若检查和写入本身还能被别人的操作插开,单独比较版本号仍不足以保护名额。
四个操作总共有 种排列。保留每个人“先读后写”的次序后,有六种合法交错。程序把它们全部枚举:
| 提交规则 | 超额的交错数 |
|---|---|
| 仅相信自己先前读到的空闲状态 | 4/6 |
| 提交时原子检查版本 | 0/6 |
这里的 4/6 是六种顺序中有四种出错,不是测得真实系统有三分之二概率出错。实际调度并不一定等概率;本实验也没有启动并行线程或让两个语言模型对话。
原子检查保证容量没有被突破,却没有让两个任务都成功。拒绝之后如何重新协商、谁优先、能否换时间,仍需要工作流或 Agent 决策。把一致性约束交给环境,并不会自动解决业务目标之间的冲突。
合作也可能伴随竞争
如果两个 Agent 为不同的人服务,目标未必一致。一个想买得便宜,另一个想卖得贵;“彼此交流更多”也不能直接等同于“共同完成同一个任务”。
2026 年的 CoffeeBench 让两家农场、两家烘焙商和两家零售商经营 90 个模拟日。被评模型控制其中一家烘焙商,其他企业由固定参考 Agent 控制;交易、库存和现金将行动串成长期后果。论文还记录了计划看似连贯、行动却逐渐变成等待的现象。这与我们关注的停滞问题相通,但环境和证据完全不同:作者每个模型仅运行三次,模拟经济也不能代表真实经营。CoffeeBench 原文第 3、5、7 节
6. 怎样评价“真的做成了”
最终状态之外,还看过程
只读最后一句话,很容易把 I 对应的“结束”看成成功。只检查“曾经打印过”,又会把重复打印两页放过去。评价应回到任务约定的状态与行为。
在这个环境里,至少要区分:最后是否满足目标、有没有产生多余页数、是否在被拒绝前擅自转交,以及用了多少动作。动作少只有在任务确实完成时才有意义;九步转交失败,不比十二步正确完成更高效。
评价程序本身也会出错。可以先用明显的正反例检查它:初始状态不能通过;直接结束不能通过;打印一页但不结束不能通过;打印两页再结束不能通过;正常一页再结束才通过。每次新案例还要重置状态,避免前一项已经装好的纸悄悄帮助下一项。
一次成功,和反复可靠地成功
“多试几次,总有一次能做成”和“每次交给它都稳妥”是不同问题。
假设同一个任务运行 次,其中 次成功,再从这些记录中均匀选出 次,不放回。所选的 次全部成功的比例为:
是从 个对象中挑 个、不考虑顺序的组合数。相反,所选尝试中至少一次成功的比例为:
第二式用“全部选中失败”的反面来计算。当可选数量少于 时,相应组合数取 0;这里要求 。若把这些比例用作新运行的估计,还要考虑重复实验是否来自相同条件,以及样本量是否足够。
例如,五次中四次成功,选两次:总共十对,其中六对全成功,却每一对都至少有一次成功。因此:
τ²-Bench 使用重复运行考察可靠性;阅读时要留意上标 与 @ 符号的区别。我们的八个起点是八个不同案例,各配置没有重复采样,不能拿它们冒充同一任务的重复成功率。τ²-Bench 原文第 4 节
环境也能提供训练经验
如果把交互轨迹、任务结果和奖励用于更新模型参数,环境就不只是考场,也成了练习场。但一次工具返回被追加进上下文,并不等于模型权重完成了一次训练;本页所有模型运行都保持权重不变。
2026 年 10 月的 MAI-Gym 预印本把 GUI 任务组织成环境、初始状态、目标与验证器,并让执行流程连接评测、数据与强化学习。其任务生成流程会检查初始未完成、参考状态完成、重置后未完成,再检验任务是否能通过实际界面操作到达。直接改数据库构造完成状态,可帮助检查验证器,却不能替代 Agent 使用界面的能力。MAI-Gym 原文第 2、3 节
模拟环境里的进步,还要走过迁移这一步。该论文在 52 个配对购票任务上报告:仅在克隆应用中进行强化学习后,真实手机应用成功率从 28.8% 升到 48.1%;训练后的克隆应用成绩为 73.1%。改善确实存在,两种环境仍有明显差距。这是作者在特定应用上的报告,本书没有运行其训练,也不能据此认定所有模拟经验都会迁移。MAI-Gym 原文第 4.3.4 节
7. 把实验跑起来
先运行不需要模型的部分
在仓库根目录运行:
python3 src/examples/agent_environment.py
程序读取随书保存的记录,输出:
手写观察策略:8/8
小模型 full:0/8,共 72 次动作选择
小模型 receipt:0/8,共 128 次动作选择
名额争用 stale_check:4/6 种交错超额
名额争用 revision_check:0/6 种交错超额
也可以下载环境与汇总脚本,在它旁边建立 data 文件夹,放入固定方案、参考策略与争用记录和完整模型记录。
默认命令只汇总已有记录。要重新执行手写策略与全部交错,运行:
python3 src/examples/agent_environment.py --controls agent-environment-controls-new.json
这部分只使用 Python 标准库。输出文件不能已存在。先读 Printer.step 看环境怎样变化,再读 reference_episode 看策略怎样根据观察行动,最后读 contention 看六种交错怎样枚举。
展开完整环境与汇总代码
"""Agent 与环境交互:可重放的打印机环境、观察基线与资源争用。"""
from argparse import ArgumentParser
from copy import deepcopy
from hashlib import sha256
from itertools import permutations
import json
from pathlib import Path
DATA = Path(__file__).with_name("data")
PLAN = DATA / "agent-environment-plan.json"
RECORD = DATA / "agent-environment-model.json"
CONTROL = DATA / "agent-environment-control.json"
FIELDS = ["enabled", "queue_clear", "power", "paper", "cable"]
ACTIONS = {
"A": "inspect_server: read enabled and queue_clear",
"B": "ask_device_status: read power, paper and cable from the simulated user",
"C": "enable_server: set enabled=true",
"D": "clear_queue: set queue_clear=true",
"E": "ask_power_on: request the user to set power=true",
"F": "ask_load_paper: request the user to set paper=true",
"G": "ask_connect_cable: request the user to set cable=true",
"H": "print_page: print one page only when all five readiness fields are true",
"I": "finish: end the task as completed",
"J": "wait: let one interaction tick pass",
"K": "handoff: end unresolved after a required user operation was refused",
}
FIXES = {"enabled": "C", "queue_clear": "D", "power": "E", "paper": "F", "cable": "G"}
def digest(path):
return sha256(Path(path).read_bytes()).hexdigest()
def fingerprint(value):
return sha256(json.dumps(value, sort_keys=True, ensure_ascii=False,
separators=(",", ":")).encode()).hexdigest()
def save_new(path, value):
with Path(path).open("x") as stream:
json.dump(value, stream, ensure_ascii=False, indent=2)
stream.write("\n")
def make_plan():
specifications = [
("ready", [], 0, None),
("server", ["enabled", "queue_clear"], 0, None),
("paper", ["paper"], 0, None),
("device", ["power", "cable"], 0, None),
("mixed", ["enabled", "paper"], 2, None),
("all-now", FIELDS, 0, None),
("all-delayed", FIELDS, 3, None),
("paper-unavailable", ["paper"], 0, "paper"),
]
return dict(
version=1, date="2026-10-20", model="Qwen/Qwen2.5-0.5B-Instruct",
revision="7ae557604adf67be50417f59c2c2f167def9a775",
settings=dict(threads=4, seed=2026, dtype="float32", attention="eager",
max_steps=16, input_plus_output_cap=4096,
selection="argmax next-token logit restricted to A..K; no sampling"),
feedback=["full", "receipt"],
cases=[dict(id=name, initial={key: key not in faults for key in FIELDS},
user_delay=delay, unavailable=unavailable)
for name, faults, delay, unavailable in specifications],
selection="All fixed cases and both feedback modes; no result-based prompt selection",
)
class Printer:
def __init__(self, case):
self.case = deepcopy(case)
self.state = deepcopy(case["initial"])
self.clock, self.printed = 0, 0
self.pending, self.denied = {}, []
self.terminal = None
def snapshot(self):
return dict(clock=self.clock, readiness=deepcopy(self.state),
pending=deepcopy(self.pending), denied=self.denied[:],
printed=self.printed, terminal=self.terminal)
def step(self, action):
if action not in ACTIONS or self.terminal is not None:
raise ValueError("动作不合法,或任务已结束")
self.clock += 1
events = []
for key, due in list(self.pending.items()):
if due <= self.clock:
self.state[key] = True
del self.pending[key]
events.append({"user_completed": key})
status, details = "ok", {}
if action == "A":
details = {key: self.state[key] for key in FIELDS[:2]}
elif action == "B":
details = {key: self.state[key] for key in FIELDS[2:]}
elif action in {"C", "D"}:
key = "enabled" if action == "C" else "queue_clear"
self.state[key] = True
details = {key: True}
elif action in {"E", "F", "G"}:
key = {"E": "power", "F": "paper", "G": "cable"}[action]
if self.state[key]:
status = "already_ready"
elif self.case["unavailable"] == key:
status = "refused"
details = {"required_operation_unavailable": key}
if key not in self.denied:
self.denied.append(key)
elif key in self.pending:
status, details = "pending", {"due": self.pending[key]}
elif self.case["user_delay"] == 0:
self.state[key] = True
status, details = "completed", {key: True}
else:
self.pending[key] = self.clock+self.case["user_delay"]
status, details = "accepted", {"due": self.pending[key]}
elif action == "H":
missing = [key for key in FIELDS if not self.state[key]]
if missing:
status, details = "not_ready", {"missing": missing}
else:
self.printed += 1
status, details = "printed", {"total_pages": self.printed}
elif action == "I":
self.terminal = "finished"
status = "finished"
elif action == "K":
self.terminal = "handoff"
status = "handoff"
return dict(clock=self.clock, status=status, details=details, events=events)
def grade(self):
if self.case["unavailable"]:
success = (self.terminal == "handoff" and self.printed == 0
and self.case["unavailable"] in self.denied)
else:
success = self.terminal == "finished" and self.printed == 1
return dict(success=success, printed=self.printed, terminal=self.terminal,
steps=self.clock, unresolved_fields=[k for k in FIELDS if not self.state[k]])
def visible(result, mode):
if mode == "full":
return deepcopy(result)
if mode == "receipt":
return {key: result[key] for key in ["clock", "status"]}
raise ValueError(mode)
def messages(history, mode):
menu = "\n".join(f"{key}: {value}" for key, value in ACTIONS.items())
instruction = (
"You operate a simulated printer. Print exactly ONE test page, then finish. "
"Initial readiness is unknown. Tools are the only way to observe or change it. "
"Physical changes require the simulated user; accepted means requested, not completed. "
"User operations may take several interaction ticks. Every action advances one tick. "
"Inspect, fix the missing conditions, verify feedback, and stop when done. "
"If a required user operation is refused, hand off instead of declaring success. "
"Choose exactly one letter A through K for the NEXT action. Do not explain.\n"+menu)
note = ("Tool feedback includes details and completed user events." if mode == "full" else
"Tool feedback gives only the receipt status; details and user events are hidden.")
return [
{"role": "system", "content": instruction},
{"role": "user", "content": note+"\nHistory:\n"+
json.dumps(history, separators=(",", ":"))+"\nNext action letter:"},
]
def reference_episode(case, max_steps):
"""手写观察策略;只根据已收到的回执作选择,不直接读取隐藏就绪状态。"""
env, knowledge, history, trace = Printer(case), {}, [], []
while not env.terminal and env.clock < max_steps:
if any(row["observation"]["status"] == "refused" for row in history):
action = "K"
elif any(row["observation"]["status"] == "printed" for row in history):
action = "I"
elif "enabled" not in knowledge:
action = "A"
elif any(not knowledge[key] for key in FIELDS[:2]):
action = FIXES[next(k for k in FIELDS[:2] if not knowledge[k])]
elif "power" not in knowledge:
action = "B"
elif any(not knowledge[key] for key in FIELDS[2:]):
waiting = {row["action"] for row in history
if row["observation"]["status"] in {"accepted", "pending"}}
remaining = [k for k in FIELDS[2:] if not knowledge[k] and FIXES[k] not in waiting]
action = FIXES[remaining[0]] if remaining else "J"
else:
action = "H"
before = env.snapshot()
result = env.step(action)
for key, value in result["details"].items():
if key in FIELDS:
knowledge[key] = value
for event in result["events"]:
knowledge[event["user_completed"]] = True
history.append({"action": action, "observation": result})
trace.append(dict(action=action, before=before, result=result, after=env.snapshot()))
return dict(id=case["id"], trace=trace, **env.grade())
def contention():
"""枚举 A、B 各先读后写的全部交错;提交在本函数中是不可分割的一步。"""
orders = [list(order) for order in permutations(["A_read", "A_write", "B_read", "B_write"])
if order.index("A_read") < order.index("A_write")
and order.index("B_read") < order.index("B_write")]
rows = []
for mode in ["stale_check", "revision_check"]:
for order in orders:
bookings, revision, seen, events = [], 0, {}, []
for operation in order:
actor, operation_type = operation.split("_")
if operation_type == "read":
seen[actor] = dict(free=not bookings, revision=revision)
result = deepcopy(seen[actor])
elif not seen[actor]["free"]:
result = "already_busy"
elif mode == "revision_check" and seen[actor]["revision"] != revision:
result = "conflict"
else:
bookings.append(actor)
revision += 1
result = "reserved"
events.append(dict(actor=actor, operation=operation_type, result=result,
bookings=bookings[:], revision=revision))
rows.append(dict(mode=mode, order=order, events=events, bookings=bookings,
overbooked=len(bookings) > 1))
return rows
def control_report(plan):
return dict(source_sha256=digest(__file__), plan_sha256=fingerprint(plan),
reference=[reference_episode(case, plan["settings"]["max_steps"])
for case in plan["cases"]], contention=contention())
def main():
parser = ArgumentParser(description=__doc__)
parser.add_argument("--controls", type=Path)
args = parser.parse_args()
if args.controls:
save_new(args.controls, control_report(make_plan()))
print(f"已保存观察策略与全部交错顺序:{args.controls}")
return
plan = json.loads(PLAN.read_text())
control = json.loads(CONTROL.read_text())
record = json.loads(RECORD.read_text())
assert record["complete"]
assert record["plan_sha256"] == control["plan_sha256"] == fingerprint(plan)
n = len(plan["cases"])
print(f"手写观察策略:{sum(r['success'] for r in control['reference'])}/{n}")
for mode in plan["feedback"]:
rows = [r for r in record["rows"] if r["mode"] == mode]
print(f"小模型 {mode}:{sum(r['grade']['success'] for r in rows)}/{n},"
f"共 {sum(len(r['trace']) for r in rows)} 次动作选择")
for mode in ["stale_check", "revision_check"]:
rows = [r for r in control["contention"] if r["mode"] == mode]
print(f"名额争用 {mode}:{sum(r['overbooked'] for r in rows)}/{len(rows)} 种交错超额")
if __name__ == "__main__":
main()
让本地小模型重新选择动作
模型脚本默认同样不下载或加载权重:
python3 src/examples/agent_environment_models.py
输出:
默认不加载模型。使用 --run --out-dir 新目录,才运行 16 条交互轨迹。
显式重跑时,在仓库根目录使用固定依赖:
uv run --python 3.12 --with-requirements src/examples/requirements-inference.txt \
python src/examples/agent_environment_models.py \
--run --out-dir runs/agent-environment-new
首次运行需要联网取得固定模型;已有缓存时可以复用。float32 权重与运行缓冲需要数 GB 内存。本书这次 200 次前向计算及相邻步骤记录的计时合计约 86.7 秒,加载、文件哈希与逐步 JSON 保存另计,不能把它当成所有设备上的耗时承诺。
输出目录必须是新目录。程序先保存方案,再加载模型;每一步保存完整提示、词元 ID、十一项分数、实际环境结果、可见反馈和状态快照,同时记录代码、模型文件与依赖版本。状态快照用于事后复核,没有送进模型输入。
如果修改提示、模型或工具说明,请给新实验另起目录,并保留全部案例。可以观察它是否开始主动查询、是否真的打印、是否会重复打印;不要只看总体成功数是否上升。
展开完整模型运行代码
"""显式 --run 才加载固定小模型,在真实执行的教学环境中选择动作。"""
from argparse import ArgumentParser
from datetime import datetime, timezone
from importlib.metadata import version
import json
from pathlib import Path
import platform
import time
import agent_environment as lab
def experiment(directory):
directory.mkdir(parents=True, exist_ok=False)
plan = lab.make_plan()
lab.save_new(directory/"plan.json", plan) # 在加载模型、观察结果前固定。
lab.save_new(directory/"control.json", lab.control_report(plan))
import torch
from huggingface_hub import snapshot_download
from transformers import AutoModelForCausalLM, AutoTokenizer
settings = plan["settings"]
torch.set_num_threads(settings["threads"])
torch.manual_seed(settings["seed"])
snapshot = Path(snapshot_download(plan["model"], revision=plan["revision"],
allow_patterns=["*.json", "*.safetensors", "merges.txt",
"README.md", "LICENSE"]))
tokenizer = AutoTokenizer.from_pretrained(snapshot, trust_remote_code=False)
letters = list(lab.ACTIONS)
ids = [tokenizer.encode(letter, add_special_tokens=False) for letter in letters]
assert all(len(row) == 1 for row in ids)
token_ids = [row[0] for row in ids]
assert len(set(token_ids)) == len(letters)
model = AutoModelForCausalLM.from_pretrained(
snapshot, dtype=torch.float32, attn_implementation=settings["attention"],
trust_remote_code=False).eval()
report = dict(
complete=False, started_at=datetime.now(timezone.utc).isoformat(),
model=plan["model"], revision=plan["revision"], settings=settings,
plan_sha256=lab.fingerprint(plan), action_tokens=dict(zip(letters, token_ids)),
source_sha256={p.name: lab.digest(p) for p in [Path(__file__), Path(lab.__file__)]},
model_files_sha256={p.name: lab.digest(p) for p in snapshot.iterdir()
if p.is_file() and (p.suffix in {".json", ".safetensors"}
or p.name == "merges.txt")},
environment=dict(python=platform.python_version(), platform=platform.platform(),
packages={name: version(name) for name in
["torch", "transformers", "huggingface-hub",
"tokenizers", "safetensors"]}),
rows=[],
)
def checkpoint():
temporary = directory/"model.pending.json"
temporary.write_text(json.dumps(report, ensure_ascii=False, indent=2)+"\n")
temporary.replace(directory/"model.json")
checkpoint()
for mode in plan["feedback"]:
for case in plan["cases"]:
env, history = lab.Printer(case), []
row = dict(id=case["id"], mode=mode, trace=[], stopped="step_limit")
report["rows"].append(row)
for _ in range(settings["max_steps"]):
conversation = lab.messages(history, mode)
inputs = tokenizer.apply_chat_template(
conversation, tokenize=True, add_generation_prompt=True,
return_dict=True, return_tensors="pt")
length = inputs["input_ids"].shape[1]
if length+1 > settings["input_plus_output_cap"]:
row["stopped"] = "context_budget"
break
started = time.perf_counter()
with torch.inference_mode():
scores = model(**inputs).logits[0, -1]
selected = scores[token_ids]
probabilities = selected.softmax(0).tolist()
action = letters[int(selected.argmax())]
before = env.snapshot()
result = env.step(action)
observation = lab.visible(result, mode)
trace = dict(
messages=conversation, input_ids=inputs["input_ids"][0].tolist(),
input_tokens=length, action=action, action_token=token_ids[letters.index(action)],
logits=selected.tolist(), conditional_probabilities=probabilities,
legal_token_mass=scores.softmax(0)[token_ids].sum().item(),
seconds=time.perf_counter()-started, before=before,
result=result, observation=observation, after=env.snapshot())
row["trace"].append(trace)
history.append(dict(action=action, observation=observation))
row["grade"] = env.grade()
checkpoint()
if env.terminal:
row["stopped"] = env.terminal
break
row["grade"] = env.grade()
checkpoint()
print(f"{mode}/{case['id']}: success={row['grade']['success']} "
f"steps={len(row['trace'])} actions="
f"{''.join(step['action'] for step in row['trace'])}", flush=True)
report["complete"] = True
report["finished_at"] = datetime.now(timezone.utc).isoformat()
checkpoint()
print(f"完成 16 条真实模型交互轨迹:{directory}", flush=True)
def main():
parser = ArgumentParser(description=__doc__)
parser.add_argument("--run", action="store_true")
parser.add_argument("--out-dir", type=Path)
args = parser.parse_args()
if not args.run:
print("默认不加载模型。使用 --run --out-dir 新目录,才运行 16 条交互轨迹。")
return
if args.out_dir is None:
parser.error("需要尚不存在的 --out-dir")
experiment(args.out_dir)
if __name__ == "__main__":
main()
也可下载模型运行脚本,与环境脚本放在同一目录。依赖与安装方式可回查工具附录。
8. 停下来试一试
- 只有纸张未就绪,用户操作延迟两步。第 1 步请求装纸,第 2、3 步分别打印。哪次能成功?为什么?
- A 返回服务已启用、队列已清空。是否足以认定可以打印?下一次查询可以补上什么信息?
- 已经打印一页却没有结束,为什么不满足本实验目标?打印两页再结束,又为什么失败?
- 每个关键环节在前面都成功时的成功概率为 0.95。计算十步与二十步全部成功的概率,并说出这个算例省略了哪些恢复机制。
- 给缺纸案例另写一份策略,先 A、B,再请求装纸,观察完成后打印并结束。把延迟改成 3,它是否仍能完成?不要直接读取
env.state来选择动作。 - 列出两个执行者各自先读后写的六种交错。哪两种不会让“仅相信旧读数”的规则超额?
- 若版本检查和实际写入是两个独立步骤,两个执行者能否都先通过版本 0 的检查?这说明图 3 依赖什么假设?
- 同一任务五次中四次成功,抽两次时,全部成功与至少一次成功分别是多少?如果不知道哪次成功,“至少一次”是否自动意味着能选出正确结果?
- full 与 receipt 都得到 0/8,能否判断删掉反馈细节不会影响 Agent?请结合实际动作序列回答。
- 把一次失败轨迹追加到下一轮提示,与用失败轨迹计算训练损失并更新参数,有什么区别?两种方法都在哪些地方可能过度适应这八个案例?
参考答案与实验提示
- 第 1 步将完成时钟设为 3;第 2 步仍缺纸,打印失败。第 3 步先兑现装纸事件,再执行打印,所以这次成功。最终还需要结束动作。
- 不足以;开机、纸张和接线仍未知。B 能返回这三项设备状态。询问完成后,真实系统里还要考虑状态是否又发生变化。
- 目标同时要求恰好一页与明确结束。前者少了结束,后者多打印了一页;“曾经做对过一步”不是最终目标。
- 分别约为 0.599 与 0.358。算例没有重试、回滚、检查后改正或替代路径,不能直接预测真实系统可靠性。
- 可以参照
reference_episode,根据回执和完成事件维护已知状态。迟迟没有事件时不能自行把纸张设为就绪;等待仍计入 16 步预算。另试用户拒绝装纸,应转交而非继续等待。 - 用
Ar、Aw、Br、Bw表示读写,六种依次为:Ar Aw Br Bw、Ar Br Aw Bw、Ar Br Bw Aw、Br Bw Ar Aw、Br Ar Bw Aw、Br Ar Aw Bw。第一与第四种不会超额,因为后一人读时已经占用。 - 可以。两人都先读到 0、检查通过,再各自写入。检查与写入必须作为原子操作执行,或有提供同等约束的事务机制;版本号本身不是锁。
- 全部成功为 0.6,至少一次为 1。后者只说明候选中存在成功尝试,还需要能识别并选择它;参见推理与测试时计算。
- 不能。模型没有进入打印与延迟用户操作的关键阶段,同一模式下也没有获取足以区分案例的信息。需要更多能使用工具的配置、独立案例及重复实验,再判断反馈的影响。
- 追加历史改变本次输入,训练改变参数。反复按这八题修改提示,或只用它们更新权重,都可能记住局部规律;应另设开发与留出任务,测试不同故障组合、延迟和用户行为。
9. 沿着交互过程继续读
可以把论文放在同一条问题线上:模型看到什么,能做什么,别人怎样参与,最后怎样确认完成。
- 分析怎样与行动相接? Yao 等,ReAct: Synergizing Reasoning and Acting in Language Models,v3,2023-03-10。阅读第 2 节的交互形式,再比较语言分析与环境动作的不同作用。
- 工具接口会怎样影响模型行为? Yang 等,SWE-agent: Agent-Computer Interfaces Enable Automated Software Engineering,v3,2024-11-11。对照第 3 节与第 5.1 节的接口设计和消融条件。
- 用户也能改变环境时,任务怎样定义? Barres 等,τ²-Bench: Evaluating Conversational Agents in a Dual-Control Environment,v1,2025-06-09。阅读第 3、4 节,再查作者代码中的任务与状态约定。
- 多个不同角色怎样长期互动? Sugiura 等,CoffeeBench: Benchmarking Long-Horizon LLM Agents in Heterogeneous Multi-Agent Economies,v1,2026-06-15。先看第 3 节环境与第 7 节局限,可沿作者代码与轨迹入口继续核查。
- 如何让环境既能评测,也能训练? Tong 等,MAI-Gym: An Open All-in-One Platform for GUI Agent Evaluation and RL Training across Mobile and Computer Environments,v1,2026-10-14。这是一篇近期预印本。重点看第 2、3 节与第 4.3.4 节的任务验证与模拟到真实的迁移范围。
以后看到一个 Agent 演示,可以沿着它的行动问下去:哪条反馈让它改变了决定?哪个状态证明事情已经完成?如果别人晚了一步,它还能找到接下来的路吗?
回到前沿专题库,下一个问题会从“这次任务能否做完”延伸到“经历许多任务之后,模型怎样学到新东西,又保留已有能力”。