跳转至

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.jsonl 18,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. 目标

  1. proxy 转发前把请求体 tools 按函数名(function.name)稳定排序;
  2. 语义等价:仅重排数组,不改内容、不增删;
  3. 同一会话内该数组顺序恒定,消除顺序抖动引起的轮首冷启动;
  4. 幂等:已有序时不改写、返回 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 同步,请勿在站点侧修改)