SOP:proxy 侧规范化 tools 顺序(消除轮首冷启动)¶
- 主题目录:
tools-order-20261007/|实施日期:2026-10-07|实施人:Agent(用户授权) - 实现体:
proxy.py(新增normalize_tools_order();调用点_prepare_body())
1. 背景¶
Trae 客户端在每一轮开始时重新组装 tools 数组:内容一字未改,仅顺序抖动。上游以请求前缀判定缓存命中,顺序一变即整段前缀失效(触发一次全量重建)。
实测依据(2026-10-07,logs/usage.jsonl 全库 18,432 步 / 263 会话,短间隔 ≤5min 且单步 miss ≥5万):
| 类 | 步数 | miss tok | 金额 | 性质 |
|---|---|---|---|---|
| 轮首·工具顺序抖动 | 21 | 4,337,040 | 4.34 元 | 客户端行为 → 可修 |
| 非轮首·内容未变 | 21 | 1,924,572 | 1.92 元 | 上游 best-effort → 不可修 |
未匹配(req_shape 覆盖 93%) |
8 | 1,239,682 | 1.24 元 | — |
| 合计 | 50 | 7,501,294 | 7.50 元 |
判据:那 21 步全部落在轮首,tools_hash 不同、但 tool_defs 与排序后的 tool_names 完全相同 → 真实增删为 0,纯顺序抖动。
2026-10-08 复核注记(口径还原 + 方法学)
上表「21 步 / 4.34 元」用的是当时的口径,用当前
wrapup.py复现不出来(全库跑出 0 步)。根因: 判据依赖shape.tool_defs,而该字段自 2026-10-06 22:36 起才有值(req_shape.jsonl18,394 行中仅 1,879 行有), 故当前判据的有效窗口只有约 26 小时,那 21 步绝大部分落在窗口之前。复核结论(详见
tools-norm-verify-20261007/SOP.md): - 「生效后 = 0 步」可靠 —— 生效后数据完整、字段齐备,三种起点(全库 / 05:55:24 / 07:35:09)均为 0 步; - 「生效前 = 21 步 / 4.34 元」不可复现 —— 引用时须注明系原口径,勿当作可复算的数字。方法学(易错点):
tools_hash只在同会话内可比。不同会话的工具集与顺序本就不同, 跨会话比较会把「会话切换」全算成「变化」(实测:不分组 301 次、按conv_key分组仅 4 次)。
2. 目标¶
- proxy 转发前把请求体
tools按函数名(function.name)稳定排序; - 语义等价:仅重排数组,不改内容、不增删;
- 同一会话内该数组顺序恒定,消除顺序抖动引起的轮首冷启动;
- 幂等:已有序时不改写、返回
False。
不追求:非轮首那 1.92 元(上游 best-effort,proxy 无从干预)。
3. 前置条件¶
- 已备份
proxy.py(见 §7); python3 -c "import proxy"可导入且无副作用;- 变更只作用于
_prepare_body()的请求体预处理,不触碰转发路径与响应处理。
4. 影响范围¶
| 项 | 内容 |
|---|---|
| 主机 | 本机 |
| 组件 | proxy.py:normalize_tools_order() + _prepare_body() 调用点 |
| 服务 | systemd 用户服务 token-proxy.service(systemctl --user 管理),监听 8788;实现进程 venv/bin/python proxy.py config.yaml |
| 是否停服 | 需重启服务才生效;重启会打断在途流式回答 |
| 窗口期 | 选无在跑任务时,约数秒 |
| 影响面 | 全局——所有经该代理的会话,故 §6 验证必须做足 |
5. 分步操作¶
步骤 1:备份¶
改前先备份,失败可原样回退。
cd ~/Documents/services/token_proxy
mkdir -p tools-order-20261007/backup
cp -p proxy.py "tools-order-20261007/backup/proxy.py.bak.$(date +%Y%m%d-%H%M%S)"
ls -l tools-order-20261007/backup/
预期:目录下出现 proxy.py.bak.<时间戳>。
失败判断:目录不可写 → 先查权限,不跳过备份继续。
步骤 2:新增 normalize_tools_order()¶
在 apply_prompt_hint()(proxy.py 约 537 行)之前插入函数定义。
注意事项与坑:
- 数组元素可能不是 dict(如
'junk'、{'no_fn':1})→_key返回"",排序不报错(已实测混合数组可正常排序); len(tools) < 2、无tools、tools非 list → 直接返回False;- 原地修改
obj;排序结果与原始相同则不赋值(保证幂等,避免无谓重写请求体)。
grep -n "def normalize_tools_order" proxy.py
预期:命中 1 行。失败判断:未命中 → 编辑未生效,重做本步。
步骤 3:在 _prepare_body() 末尾调用¶
在 return json.dumps(obj, ...) 之前加一行:
meta["tools_renormalized"] = normalize_tools_order(obj)
坑:必须放在 build_shape_fingerprint(obj) 之后——否则 shape.tools_hash 会变成"排序后的 hash",破坏既有分析口径(该指纹用于区分"顺序抖动"与"真实增删")。
grep -n "tools_renormalized" proxy.py
预期:命中 1 行。
步骤 4:语法与离线验证(上机前必做)¶
cd ~/Documents/services/token_proxy
python3 -c "import ast;ast.parse(open('proxy.py',encoding='utf-8').read());print('syntax OK')"
python3 -c "
import proxy
o={'model':'m','tools':[{'function':{'name':'zeta'}},{'function':{'name':'alpha'}},{'function':{'name':'mid'}}]}
print(proxy.normalize_tools_order(o), [t['function']['name'] for t in o['tools']])
print(proxy.normalize_tools_order(o))
"
预期:syntax OK;第一行 True ['alpha', 'mid', 'zeta'];第二行 False(幂等)。
失败判断:抛异常或顺序未变 → 回到 §7 回滚。
本次已实测(2026-10-07):单元用例全过 + _prepare_body 路径端到端通过——乱序转有序、工具 parameters 内容保持、model/stream/messages 不变、原有 stream_options.include_usage 注入仍生效、无 tools 请求返回 False。
步骤 5:重启代理使其生效¶
高危:重启会打断正在进行的流式回答。
注意事项与坑:run.sh 与 systemd 互斥——本机 proxy 由 systemd 用户服务管理,必须用 systemctl --user restart,勿手工 nohup 或 run.sh(会与 systemd 抢 8788)。
systemctl --user restart token-proxy
sleep 2
systemctl --user is-active token-proxy # 预期 active
ss -lntp | grep 8788 # 预期 LISTEN,PID 与重启前不同
预期:is-active 输出 active;8788 在监听,且进程 PID 已更新。
失败判断:非 active → journalctl --user -u token-proxy -n 50 --no-pager 查因;仍失败 → §7 回滚。
6. 验证¶
① 健康检查
curl -s http://127.0.0.1:8788/healthz
预期:{"status":"ok","routes":[...]}。
② 转发正常:正常对话一轮,确认流式回答无异常,且服务日志无新 ERROR:
journalctl --user -u token-proxy -n 20 --no-pager
③ 收益验证(跨天):已内置到 cost_audit/wrapup.py——有异常才输出(2026-10-07 用户要求):
**tools 顺序抖动(<生效时刻> 之后):N 步**(常量 TOOLS_NORM_SINCE;归零即静默,不占位)。
预期:重启之后该计数不再增长(只看时间戳晚于生效时刻的记录;历史记录仍计入全量口径)。
静默的两种含义要分清:① 无样本(刚重启);② 已验证归零——二者输出相同,且注记:N>0 即提示该规范化未生效或失效。下面这条手工命令保留作复核:
cd ~/Documents/services/token_proxy && python3 -c "
import json,datetime
from collections import defaultdict
u=[json.loads(l) for l in open('logs/usage.jsonl') if l.strip()]
u=[r for r in u if r.get('conv_key') and r.get('prompt_tokens')]
s=[json.loads(l) for l in open('logs/req_shape.jsonl') if l.strip()]
shp={(r['conv'],r['ts']):r for r in s}
def ep(v):
if isinstance(v,(int,float)): return float(v)
return datetime.datetime.fromisoformat(str(v).replace('Z','+00:00')).timestamp()
by=defaultdict(list)
for r in u: by[r['conv_key']].append(r)
hit=[]; miss=[]
for conv,rs in by.items():
rs=sorted(rs,key=lambda r: ep(r['ts'])); prev=None
for r in rs:
if prev:
g=ep(r['ts'])-ep(prev['ts']); p=r['prompt_tokens']; pp=prev['prompt_tokens']
if g<=300 and p>=pp*0.95 and (r.get('cache_miss_tokens') or 0)>=50000:
a=shp.get((conv,r['ts'])); b=shp.get((conv,prev['ts']))
if a and b:
if a['shape'].get('tools_hash')!=b['shape'].get('tools_hash'): hit.append(r['ts'])
else: miss.append(r['ts'])
prev=r
print('顺序抖动类:', len(hit), '非轮首内容未变类:', len(miss))
print('顺序抖动最近 3 例:', hit[-3:] if hit else '(无)')
"
预期:重启之后不再出现新的"顺序抖动"记录(历史记录仍会命中,判断时只看时间戳是否晚于重启时刻)。
7. 回滚¶
- 备份位置:
tools-order-20261007/backup/proxy.py.bak.<时间戳>;台账见 backup/README.md。 - 回滚步骤:
cd ~/Documents/services/token_proxy
cp -p "tools-order-20261007/backup/proxy.py.bak.<时间戳>" proxy.py
python3 -c "import ast;ast.parse(open('proxy.py',encoding='utf-8').read());print('syntax OK')"
systemctl --user restart token-proxy
sleep 2
systemctl --user is-active token-proxy && ss -lntp | grep 8788
- 回滚后验证:
/healthz返回 ok + 一轮对话可正常流式返回。
8. 风险说明¶
| 风险 | 说明 | 缓解 |
|---|---|---|
| 改动了请求体 | 排序改变了发往上游的字节 | 只重排、不改内容;离线验证字段不变;首次排序后会 miss 一次,其后稳定 |
| 全局生效 | 影响所有经代理的会话 | 离线验证充分 + 一键回滚 |
| 上游对顺序敏感 | 若上游依赖特定顺序(概率极低) | 发现异常行为立即按 §7 回滚 |
| 重启打断在途回答 | §5 步骤 5 属高危 | 选无任务窗口;必要时等当前轮结束 |
| 收益不确定 | 依赖"Trae 抖动是每轮都变" | 生效后按 §6 ③ 量化;若收益为 0 则回滚,不留无用复杂度 |
附录¶
- 交付物清单:
SOP.md(本文)、backup/proxy.py.bak.<时间戳>、backup/README.md - 决策经过:由"是否拦截异常请求"讨论引出——先证伪"闲置触发/到量触发"两个假说,再按
tools_hash二分定位,最终只做判得准的那一半。 - 未做项:非轮首 best-effort miss(1.92 元)与上游 TTL(2.61 元,误报率 76~86%)均已论证不值得干预。
来源:
/home/rain/Documents/services/token_proxy/tools-order-20261007/SOP.md| 最后更新:2026-10-08 00:43(由build/sync.py同步,请勿在站点侧修改)