SOP:收紧 wrapup.py 的「tools 顺序抖动」判据¶
变更日期:2026-10-07 执行人:Agent(用户授权) 对象:cost_audit/wrapup.py
1. 背景¶
收尾自查里有一行「tools 顺序抖动(TOOLS_NORM_SINCE 之后):N 步」,用于验证 proxy 的 normalize_tools_order()(转发前按 function.name 排序)是否消除了 Trae 重排 tools 数组导致的冷启动。
它长期报「1 步(08:41:40)」。2026-10-07 取证(req_shape.jsonl,05:55:24 之后 569 步)后确认这是误报:
| 项 | 实测 |
|---|---|
distinct 原始 tool_defs 序列 |
2 |
| distinct 排序后集合 | 2 |
| 差异 | 仅少 AskUserQuestion,无其它增删 |
| n=27(含该工具) | 567 步 |
| n=26(缺该工具) | 2 步(08:41:40、08:41:53,随后恢复 27) |
判据是:若真有顺序抖动,「原始序列种类」应多于「集合种类」;实测两者相等(2 = 2)→ 该窗口内一次顺序抖动都没有。
那一步(08:40:49 → 08:41:40,同 conv dda5be9c,间隔 51 s)的 miss 从 2803 跳到 54975 是真的,但成因是客户端把 AskUserQuestion 从工具列表里摘掉(27 → 26),不是顺序变化。而排序补不回缺失的工具,proxy 侧对此无解。
根因:tools_order_since() 只比 tools_hash 是否变化(tools_hash 由 json.dumps(tools, sort_keys=True) 算出,sort_keys 只排 dict 的键、不影响数组顺序,故对顺序与增删都敏感),把「工具增删」一并计成了「顺序抖动」。
2. 目标¶
tools_hash 变化时,还须工具名集合相同才计入;工具增删不计。
可验证状态:离线 4 项用例全通过;真机 wrapup.py 输出中该行消失(n 由 1 → 0)。
3. 前置条件¶
- 对
cost_audit/wrapup.py有写权限; python3可用;logs/req_shape.jsonl中目标区的记录含shape.tool_defs(2026-10-02 起有)。
4. 影响范围¶
- 主机:单机(本机 token_proxy 工程),一个文件、三处。
- 组件:
wrapup.py的tools_order_since()与其 docstring。wrapup.py是离线只读取数脚本,不在代理请求路径上。 - 是否停服:否;随改随生效。
- 窗口期:无要求。在跑作业:无影响。
5. 分步操作¶
步骤 1:备份(高危前置)¶
cd /home/rain/Documents/services/token_proxy
TS=$(date +%Y%m%d-%H%M%S)
mkdir -p wrapup-tools-jitter-20261007/backup/cost_audit
cp -p cost_audit/wrapup.py \
wrapup-tools-jitter-20261007/backup/cost_audit/wrapup.py.bak.$TS
预期:backup/cost_audit/wrapup.py.bak.<TS>(17631 B)。
失败判断:目录不可写或 cp 非 0 → 停止。
步骤 2:改三处(必须成套)¶
- docstring:在既有判据后补「2026-10-07 补:
tools_hash变化时还须工具名集合相同才计入」并写明取证依据; - 存储结构:
hash_by_key[键] = tools_hash→shape_by_key[键] = (tools_hash, frozenset(tool_defs))(tool_defs非列表时记为None); - 判据:
a and b and a != b→ 追加na is not None and na == nb。
python3 -c "import ast; ast.parse(open('cost_audit/wrapup.py', encoding='utf-8').read()); print('语法通过')"
grep -n 'shape_by_key\|na == nb' cost_audit/wrapup.py # 预期:存储 2 处 + 判据 1 处
坑:三处必须成套。只改判据不改存储,hash_by_key 里取不到第二个元素 → 解包报错;只改存储不改判据,na == nb 无人使用、等于没修。
坑:frozenset 只保留名字、丢掉顺序,这正是判据所需;不要用 sorted(defs) 的字符串比较,那会重新引入顺序敏感。
步骤 3:(无)不涉及其它文件。¶
6. 验证¶
| 层级 | 命令 | 预期 |
|---|---|---|
| 语法 | python3 -c "import ast; ast.parse(open('cost_audit/wrapup.py').read())" |
无异常 |
| 用例(4 项) | python3 wrapup-tools-jitter-20261007/verify_tools_jitter.py |
全部通过,退出码 0 |
| C1 集合相同、顺序不同 | 同上 | 计入(1 步)—— 真顺序抖动仍能被抓到 |
| C2 集合不同(少一项) | 同上 | 不计入(0 步)—— 本次修复对象 |
C3 hash 相同 / C4 tool_defs 缺失 |
同上 | 均不计入 |
| 真机 | python3 cost_audit/wrapup.py |
「tools 顺序抖动」行消失(n 0;修复前为 1) |
实测(2026-10-07):4 项用例全通过;真机该行已消失。
7. 回滚¶
备份位置:wrapup-tools-jitter-20261007/backup/cost_audit/wrapup.py.bak.20261007-084937(登记见 backup/README.md)。
cd /home/rain/Documents/services/token_proxy
cp -p wrapup-tools-jitter-20261007/backup/cost_audit/wrapup.py.bak.20261007-084937 \
cost_audit/wrapup.py
python3 cost_audit/wrapup.py # 回滚后预期:「tools 顺序抖动:1 步」重新出现
回滚后验证:该行重新报 1 步即确认回到旧判据。回滚触发量化:若用例 C1 不再计入(真顺序抖动被漏掉)→ 回滚。
8. 风险说明¶
| 风险点 | 缓解措施 |
|---|---|
漏报:2026-10-02 前的旧记录无 tool_defs → 一律不计 |
有意为之(宁漏不误报);该区的记录自 10-02 起才有 tool_defs |
| 语义变化导致与历史数字不可比 | 该统计的语义已由「hash 变化」收窄为「纯顺序抖动」,跨 2026-10-07 的数字不可直接比较(已在 docstring 写明) |
| 排序补不回工具增删,导致「工具集变化」这类冷启动无统计口径 | 本次不拓展(用户范围仅限收紧判据);若日后需要,应另立一项「工具集变化冷启动」,与本项分开报 |
误改 sort_keys 语义 |
tools_hash 的计算属 proxy 侧,本次未动 |
9. 同日再收紧:加「有序序列」判据、「定义内容变动」单列(2026-10-07 22:38)¶
9.1 背景¶
第 2 节收窄后,该行仍报「1 步(20:30:43)」。取证(会话 3367ddb1)发现:
| 项 | 实测 |
|---|---|
| 20:28:09~20:30:20(十步) | tools_hash 恒为 8b3e6ab274 |
| 20:30:43 | 切 d22e5b891a,hit=12800 / miss=84309(整段冷启动) |
| 20:30:44 | 回 8b3e6ab274(msgs 34→33,似同轮重试),miss 2790 |
| 20:31:01 起 | 稳定 d22e5b891a,命中恢复正常 |
上述三步 shape.tool_defs |
27 个工具,名字与顺序完全相同 |
名字与顺序都没变、hash 却变 → 差异在工具定义内容(描述 / schema 等,req_shape 不落内容)。
根因:第 2 节的集合判据(frozenset)主动丢弃顺序,无法区分「顺序变了」与「顺序没变、内容变了」。
9.2 目标¶
tools_hash 变化时三层分流;可验证状态:离线 5 用例全通过,真机「顺序抖动」行消失(1 → 0)、
新增「定义内容变动:1 步(20:30:43)」。
| 条件 | 归类 |
|---|---|
| 集合不同 | 工具增删 → 不计(第 2 节已定) |
| 集合相同、序列不同 | 顺序抖动(措施覆盖,计入原行) |
| 集合相同、序列也相同 | 定义内容变动(措施不覆盖,单列新行) |
9.3 影响范围¶
同第 4 节:单机、单文件;wrapup.py 为离线只读取数脚本,不在代理请求路径上;不停服、无窗口要求。
9.4 分步操作¶
- 备份(高危前置):
cp -p cost_audit/wrapup.py wrapup-tools-jitter-20261007/backup/cost_audit/wrapup.py.bak.<TS>; - 四处成套改:
cd /home/rain/Documents/services/token_proxy
grep -n 'names = (\[d for d in defs' cost_audit/wrapup.py # 存储:frozenset → 有序 list
grep -n 'set(na) == set(nb)' cost_audit/wrapup.py # 判据:改为集合比较
grep -n 'if na != nb' cost_audit/wrapup.py # 分流:序异→抖动 / 序同→内容变动
grep -n 'csamples' cost_audit/wrapup.py # 返回 4 元组 + main() 单列输出
坑:存储必须改回有序 list——第 2 节用 frozenset 正是为了丢顺序,本次要判顺序,仍用 frozenset 则 C5 与 C1 不可分。
坑:返回由 2 元组扩为 4 元组,main() 与离线用例 verify_tools_jitter.py 必须同步,否则解包报错。
- 同步给
wrapup-tools-jitter-20261007/verify_tools_jitter.py新增用例 C5。
9.5 验证¶
| 层级 | 命令 | 预期 |
|---|---|---|
| 语法 | python3 -c "import ast; ast.parse(open('cost_audit/wrapup.py', encoding='utf-8').read())" |
无异常 |
| 用例(5 项) | python3 wrapup-tools-jitter-20261007/verify_tools_jitter.py |
全通过、退出码 0 |
| 真机 | python3 cost_audit/wrapup.py |
「顺序抖动」行消失;出现「定义内容变动:1 步(20:30:43)」 |
实测(2026-10-07 22:38):语法通过;5 项用例全通过;真机输出「tools 定义内容变动(…):1 步 —— 最近:2026-10-07 20:30:43」,抖动行消失。
9.6 回滚¶
备份:wrapup-tools-jitter-20261007/backup/cost_audit/wrapup.py.bak.20261007-223813(登记见 backup/README.md)。
cd /home/rain/Documents/services/token_proxy
cp -p wrapup-tools-jitter-20261007/backup/cost_audit/wrapup.py.bak.20261007-223813 cost_audit/wrapup.py
python3 cost_audit/wrapup.py # 回滚后预期:「tools 顺序抖动:1 步」重新出现
回滚触发量化:用例 C1 不再计入(真顺序抖动被漏掉)→ 回滚。
9.7 风险说明¶
| 风险点 | 缓解措施 |
|---|---|
| 语义变动与第 2 节数字不可比 | 第 2 节的「顺序抖动」定义本次不变(仍是集合相同);本次只是把其中「序列也相同」的一类移出,故原行数字仍可比 |
| 「定义内容变动」无归因口径 | 只报步数与时刻,不猜具体字段(req_shape 不落 tools 内容);要定位需另抓原始请求体 |
| 该类是否值得再优化 | 生效后 15 h 仅 1 步 ≈ 0.084 元(≈0.13 元/天,与顺序抖动旧值 0.17 元/天同量级)——列观察项,暂不投入 |
来源:
/home/rain/Documents/services/token_proxy/wrapup-tools-jitter-20261007/SOP.md| 最后更新:2026-10-07 22:39(由build/sync.py同步,请勿在站点侧修改)