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_state 从 tool_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 会扫这些消息,把其中的工具调用重新执行一遍