CODE REVIEW · ROUND 2 · MAI-2 BACKEND · 2026-07-03

$token skill 注入改成了 Plan A:
方向对了,合并前还差三处

分支 fanye/skill_invocation_on_test · 提交 bd2fd7a(force-push,替换掉了上一版 pre_model_hook 方案)· 改动:websocket_handler.py +98 行 · triggers.py · plat_editon.md
✅ 架构落对了 · 修完 ① 和 ③ 可合,② 强烈建议顺手修
01 · 这一版做对了什么

Plan A 的核心收益都拿到了

入口一次解析,结果落 checkpoint

hint 嵌进用户消息本体,后续所有 model call 白拿。连上一版的 turn-start 门控判断都省了 —— 每条消息最多查一次库,之后永远是零。

两个入口都覆盖,没漏

主路径 _handle_chat_message 和 edit-retry 路径都接了 _prepend_skill_hint;replay 重发专门注释了不重做(原内容已带 hint)。比图片引用做得好 —— 图片引用至今主路径和 regen 路径行为不一致。

移出了 model call 关键路径

查库发生在 WS handler 收消息时,用户等 LLM 之前就做完了。DB 抖动不再直接拖长首 token 延迟。

prompt 配套改了 + 引用完整

plat_editon.md §3.4 告诉模型把 reminder 里的 name 原样照抄、不许换相似 skill。get_skill_service(L45)、_get_viewer_user_id(L509)、typing 导入都在,没有悬空引用。

顺带一个有意思的发现
正则修复 \\$\$ 不只是清理:原正则要求匹配一个字面反斜杠加行尾锚点,意味着 $token 以前根本匹配不上——这个触发功能等于从来没生效过,这次才是真启用。上线后 $xxx 的行为是全新的,值得盯一下日志。
02 · 现在的数据流

一条消息从进门到 LLM,只查一次库

1

WS 收到消息 → "$" not in content 短路

绝大多数消息在这里直接过去,零开销。

开销:一次字符串查找
↓ 消息里真写了 $xxx
2

extract_skill_tokens(limit 5) → 逐个 svc.resolve()

命中 → 生成一行"用户希望调用 xxx skill,id:…,name:…";查不到/禁用 → 静默跳过,load 工具兜底报错。

开销:命中 1 条 SQL / miss 2~3 条 SQL — ⚠️ 无缓存、同步阻塞(问题②)
3

hint 以 <system-reminder> 拼到用户消息最前面

纯文本消息 → 前缀进 content 字符串;有图片引用 → 插为 content_array 第一个 text block。随 create_user_message 落库、落 checkpoint。

之后所有 model call:0 查库,0 判断 — ⚠️ 但也进了前端可见的消息体(问题③)
场景PG 查询
用户发 "$story-designer 帮我做分镜",该轮 4 次 model call入口 1 次,之后 0
下一轮 "继续做第二个场景"(无 $)0
replay 重发老消息0(原内容已带 hint,注释里写明了不重做)
edit 改文本后重发重新解析 1 次(合理 —— 用户可能改了 $token)
用户说 "预算 $100,单价 $5"4~6 条无效 SQL(问题①,还没修)
03 · 合并前要处理的问题

两个旧的没修,两个新的要对策

① 合并前修 · 上轮已提,未修

$ + 纯数字仍会误触发查库

这次的正则改动只修了转义 bug,没加字母约束。$100 / $5 依然被当成 token,每个 miss 打 2~3 条 SQL(by_id → by_name → user_skills 三连未命中),limit=5 下一条聊钱的消息最坏白烧 10~15 条 SQL,且没有任何缓存。

修法(一行)
# skills/triggers.py:6 — token 必须至少含一个字母
- _TRIGGER_RE = re.compile(r"(?
+ _TRIGGER_RE = re.compile(r"(?
③ 合并前修 · 已确认成立

前端以 session message 为显示源 —— 用户会看到 <system-reminder>

关键事实:前端显示读的是 session message(state_manager.add_message 存的那份),不是 checkpoint。而现在同一个被污染的 content 流向了三个地方:

去向需要 hint 吗现状
create_user_messageadd_message → session message(前端显示源❌ 不需要被污染
同一对象传给 process_user_message → checkpoint / LLM 历史✅ 唯一需要的地方正确
_generate_session_name_from_message(session_id, content) → 自动会话标题❌ 不需要连带 bug:标题可能从 reminder 文本生成
修法:存干净的,传增强的(改动全在 websocket_handler,前端零改动)
# 1) 存库 / 前端展示 / 标题生成:一律用原始内容
user_message = create_user_message(session_id, content, metadata=metadata, content_array=content_array)
await self.state_manager.add_message(session_id, user_message)

# 2) 只给 agent 的那份带 hint(进 checkpoint,前端永远看不到)
agent_content, agent_array = await self._prepend_skill_hint(
    connection_id, content, content_stripped, content_array)
agent_message = copy.deepcopy(user_message)   # 保持同一 message id
agent_message.payload.content = agent_content
if agent_array is not None:
    agent_message.payload.content_array = agent_array
await self.agent_orchestrator.process_user_message(session_id, agent_message)

这一拆的三个自动收益:前端不用改(session message 永远干净);问题 ④ 消失(编辑框回填干净文本);标题 bug 消失。

连带调整 · replay 路径
现在注释写"replay 不重做,因为原内容已带 hint" —— 拆开后这个前提不成立了(存储内容不再带 hint)。建议 replay 构建下游消息时也调 _prepend_skill_hint(用户原文里 $token 还在,重新 resolve 天然可行)。三个路径统一成"构建 agent-bound 消息时调 helper",心智模型最简单。不补也能跑:基础 prompt §3.4 本来就教模型看到 $token 主动 load,hint 只是加固。
② 强烈建议 · 上轮已提,换了位置但还在

resolve() 同步 PG 调用,照样卡事件循环

svc.resolve() 内部走 psycopg 同步连接池,现在跑在 async 的 WS handler 里 —— 阻塞期间所有 session 的 WebSocket 消息都排队。移出 model call 关键路径让用户感知变小了,但事件循环层面的问题没消失。

修法
# websocket_handler.py · _build_skill_hint_text 里
- res = svc.resolve(viewer_user_id=viewer, token=tok, require_enabled=True)
+ res = await asyncio.to_thread(
+     svc.resolve, viewer_user_id=viewer, token=tok, require_enabled=True
+ )
④ 随 ③ 自动消失

编辑旧消息叠双层 hint

用户编辑一条已带 hint 的旧消息时,编辑框回填存储内容(含旧 hint),提交后 edit 路径再 prepend 一层新 reminder → 双层嵌套。

按 ③ 的"存干净的、传增强的"改完之后,存储内容里不再有 hint,这个问题不需要单独处理。

04 · 背景 · 为什么换成 Plan A

A / C 两方案的取舍(上轮协商的结论)

上一版走的是 pre_model_hook + 门控(方案 C),门控本身是对的。换成 Plan A 后的得失:

维度Plan A · 入口处(当前实现)Plan C · pre_model_hook(上一版)
查库次数每条消息 1 次,永久生效每个含 $ 的轮 1 次(每轮重判)
查库位置不在 model call 关键路径model call 前,DB 慢拖长首 token
入口数量2 处(主路径 + edit),✅ 都已覆盖1 处天然全覆盖
信息新鲜度冻结在消息里,skill 改名/禁用后旧提示过时(影响小)每轮现查,永远最新
污染用户消息会 → 问题③④不会(只进 llm_input_messages)
prompt cache无打断每轮尾部打断一次
事件循环阻塞两边都要 asyncio.to_thread,位置换了问题不消失(问题②)
* 换方案把"每轮重判 + cache 打断"换成了"消息体污染" —— 净收益为正,前提是问题③有对策
05 · 合并前 checklist

动手清单

#动作文件必要性
1正则加"至少含一个字母",挡掉 $100 误触发skills/triggers.py:6合并前
2拆两份:"存干净的、传增强的" —— session message / 标题生成用原文,只有传给 orchestrator 的副本带 hint。前端零改动,问题 ④ 和标题 bug 一并消失websocket_handler.py(主路径 + edit 路径)合并前
3replay 路径补调 _prepend_skill_hint(拆完后存储内容不再带 hint,原注释前提失效)websocket_handler.py · replay 分支跟 2 一起
4resolve 套 asyncio.to_threadwebsocket_handler.py · _build_skill_hint_text强烈建议
5上线后盯 [skill_hint] 日志 —— 这功能以前因正则 bug 从未生效,行为是全新的观察项

入口覆盖、replay 处理、prompt 配套、引用完整性这几处都验过没问题,架构不用再动。