跳到主要内容

02 · 环境、工具与轨迹重放

本章讲状态从哪来、工具怎么定义,以及一个反直觉但关键的机制:评测时环境的状态不是「跑出来的」,而是「重放消息重建出来的」

2.1 Environment:工具 + 内存数据库

一个 Environment(environment.py:34)握着四样东西:领域名、策略文本 policy、Agent 工具集 tools、可选的 User 工具集 user_tools。工具集之下是一个 DB(pydantic 模型),就是这个领域的「后台数据库」,全程活在内存里。

工具调用的统一入口是 get_response(environment.py:446):它调对应工具、sync_tools()、把返回值 JSON 化,包成 ToolMessage任何异常都被吞成 error=True 的工具消息而非崩溃——这样一次坏调用只是让 Agent 收到报错、计一次 error,模拟继续。

2.2 工具怎么定义:@is_tool 装饰器

工具就是 ToolKitBase 子类里被 @is_tool(...) 装饰的方法(toolkit.py:64)。装饰器记两件事,分清它们很重要:

属性含义谁用它
tool_type概念分类:READ/WRITE/THINK/GENERIC指标统计、prompt 展示(不管重放
mutates_state这工具会不会改 DB控制评测重放是否重跑该工具

默认 mutates_statetool_type 推断:只有 WRITE 算改状态(toolkit.py:82-83)。但可显式覆盖——比如 transfer_to_human_agents 语义上是「动作」却不改数据库,就该标 mutates_state=False这个区分是下一节重放正确性的地基。

一个最小工具示例(真实风格,参考 mock/user_tools.py:27):

# 示意,非源码。重点看:@is_tool 标类型,方法体直接读/写 self.db
@is_tool(ToolType.WRITE)
def dismiss_notification(self, notification_id: str) -> str:
"""把一条通知标记为已读。""" # docstring 会变成工具给 LLM 的说明
if notification_id not in self.db.notifications:
raise ValueError("not found") # 抛异常 → 环境包成 error 工具消息
self.db.notifications[notification_id].status = "read"
return f"dismissed {notification_id}"

还有一类 @is_discoverable_tool(toolkit.py:94):工具存在、能调,但默认不写进 Agent 的系统提示,Agent 得先从知识库里「发现」它——banking_knowledge 领域用它模拟「文档里才写着的隐藏 API」。

2.3 反直觉的核心:set_state「重放轨迹」

要解决的小问题: 打分时,裁判需要「这通电话结束后,数据库变成了什么样」。最直白的想法是「跑的时候就把状态存下来」。但 tau2 偏不——它把状态丢掉,靠事后重放消息重建。为什么?因为评测要在一个干净、确定的新环境里做,且要能对「预测轨迹」和「标准答案轨迹」用同一套逻辑各跑一遍再比。

思路: 给一个新环境,喂进整段消息历史,set_state 会扫这些消息,把其中的工具调用重新执行一遍,从而把 DB 推到终态(environment.py:293)。

重放不是无脑重跑,它有三条精心设计的规则(environment.py:357-390),怎么读:从上到下是每个工具调用要过的三道关

轨迹里的每个工具调用 tool_call

├─ 工具名不存在(模型幻觉出来的)? ──▶ 跳过(no-op,匹配线上「幻觉工具返回 error 但没改状态」)

├─ 不改状态(mutates_state=False,如 read/think)? ──▶ 跳过(避免重复读、避免非确定性输出比对出岔)

└─ 真会改状态 ──▶ 重新执行;并核对「这次返回」是否等于「轨迹里当时的返回」,不等就报错

第三关那个「核对返回值一致」很关键(environment.py:378-390):它保证重放是忠实的——如果重跑一个写操作得到的结果和当初记录的不一样,说明环境不确定或轨迹被污染,直接抛错而不是给个假状态。

幻觉工具为什么能安全跳过:注释说得很清楚(environment.py:360-372)——线上环境遇到不存在的工具名本就返回 error 且不改状态,所以重放时当 no-op 正好复现;而反复幻觉会在线上就被 max_errors 砍成 TOO_MANY_ERRORS,根本进不了评测。

2.4 状态怎么被打包成一个可比的指纹

重放完,怎么判断两个环境「状态一样」?靠整库哈希get_db_hash 把 DB 的 model_dump() 算一个字典哈希(toolkit.py:242-244),Agent 库和 User 库各一个。比状态 == 比两个哈希是否相等。这让「终态比对」变成一次廉价的字符串相等判断,也天然忽略达成路径——只要终点一样,中间怎么绕都行(03 章的 DB 奖励就建在这上面)。

2.5 一个领域由哪些文件组成

加一个领域 = 按固定骨架填几个文件(domains/README.md:6-21):

文件放什么
data_model.pyAgent 侧数据库,DB 的子类
user_data_model.pyUser 侧数据库(可选,双控领域才有)
tools.pyAgent 工具集,ToolKitBase 子类
user_tools.pyUser 工具集(可选)
environment.pyget_environment() / get_tasks() / get_tasks_split() 三个工厂
utils.py数据文件路径等

数据(tasks.jsonpolicy.mddb.json…)放在 data/tau2/domains/<领域名>/。写完在 registry.py 里登记这三个工厂,tau2 命令就能用 --domain <名> 跑它了(registry.py:313-348)。

2.6 代码地图

主题文件符号名
环境本体src/tau2/environment/environment.pyEnvironment
工具执行/吞异常src/tau2/environment/environment.pyEnvironment.get_response
轨迹重放src/tau2/environment/environment.pyEnvironment.set_state
工具装饰器src/tau2/environment/toolkit.pyis_tool
可发现工具src/tau2/environment/toolkit.pyis_discoverable_tool
是否改状态src/tau2/environment/toolkit.pyToolKitBase.tool_mutates_state
库哈希src/tau2/environment/toolkit.pyToolKitBase.get_db_hash
工具类型枚举src/tau2/environment/toolkit.pyToolType
领域登记src/tau2/registry.pyRegistry.register_domain