面试知识库

system_prompt 治理 —— 前后对比预览(先审查,不改文件)#

目标:保留同质 fork(工具集一件不减)、不做角色感知渲染;只对 prompt/prompts.ymlsystem_prompt表述去重 + 精简,逐字同质不破。309 行 → ~230 行。

硬护栏(调研得出)tests/test_m0_base.py 断言 6 个 XML 标签必须在—— <role> <workflow> <tool_policy> <termination> <constraints> <user_long_term_preferences>。 治理只精简块内文字,不消灭标签骨架。改完 pytest tests/test_m0_base.py + 种子集 rubric 前后对照。

语义无损原则:🟡 标记的刀碰的是”针对性防线”(老 bad case 沉淀),只合并措辞、不弱化语义。


刀1 —— 两 protocol「执行方」段去重 🟡(改动最大,审查重点)#

改前#

<fork_protocol> 执行方(现 200-218,19 行)+ <target_protocol> 执行方(现 239-251,13 行), 两段大量重合:都讲”直接 item_search / 别再 planner / 找够用不找最优 / 按硬约束过滤 / 空召回不编造 / 别下钻别收尾 / 产出候选或未找到”。

改后#

抽公共「执行方通则」放 <fork_protocol> 执行方;<target_protocol> 执行方只留 3 点差异,引用通则。

作为执行方(你收到的就是一条这样的子目标时)——通则:
- demands 已含主流程拆好的结构化字段与品类常识,**直接据此 item_search**,不要再调 planner /
  category_insight(系统会拦)。目标是「找够用」不是「找最优」:召回里有合适候选就立刻收敛返回,
  不为「再找更好」反复换词重搜;仅当召回明显跑题才换一次词。单平台检索次数有硬上限。
- 收敛前据硬约束自己过一遍候选标题(任何语言的「塑料/plastic/プラスチック」都读得懂),
  **标题明确违反**硬约束的剔掉;看不出违不违反的不要臆测排除(留给主流程 item_picker 兜底)。
- 空召回(total_recall=0)可松一档约束再搜**一次**;仍为 0 就如实返回「未找到」,**绝不编造商品**。
- **不要**再 fork,也**不要** price_compare / shipping_calc / item_picker / shopping_summary——
  比价 / 精挑 / 收尾统一由主流程做。产出=收敛并按硬约束过滤后的候选集(或「未找到」的如实说明)。
plaintext

<target_protocol> 执行方改为:

作为执行方(收到单商品调查 demands 时)——遵守上面「执行方通则」,本场景三点差异:
- 定位时 item_search 必须**同时**传 target_name=商品名/型号原文 **和** expected_category=demands
  里的品类(型号过滤掉型号不符的、品类过滤掉「型号对得上但其实是配件/耗材」的假阳性,缺一个都
  可能误判「找到了」)。
- 库里没有 / 召回不相关:web_search 补公开信息(价格区间、评测口碑),标注来源「库内候选」还是
  「网络检索」;两边都没有就返回「未找到可靠信息」,不编造。
- 你只看到自己这一件,**不比较不排序**(那是主流程合流后的事)。产出=这一件的调查结果 JSON。
plaintext

语义核对:双过滤定位✅ web_search 补+标来源✅ 两边没有不编造✅ 不比较✅ 不 fork 不下钻(通则覆盖)✅ 产出 JSON✅ 硬约束过滤✅ 空召回不编造✅。32 行 → ~15 行,省 ~11 行,无语义丢失。


刀3 —— item_search 双过滤说明去三留一 🟡(防线,审查重点)#

改前(三处讲同一件事)#

  • <tool_policy> item_search 条目 119-126:完整解释 target_name + expected_category 防配件假阳性
  • <target_protocol> 派发方 230:又提一次
  • <target_protocol> 执行方 240-244:又完整解释一次

改后#

  • <tool_policy> 保留完整详解一处(权威版,不动)
  • 两 protocol 处改为一句引用:「定点定位务必同时传 target_name + expected_category,理由见 <tool_policy> item_search 条」——刀1 的 target 执行方差异点已含精简版,不再第三次展开
  • 省 ~8 行。防线语义在 tool_policy 完整保留,不弱化。

刀6 —— remember_preference 参数详解精简 🟡(前置已确认,审查重点)#

前置(调研确认)#

app/tools/remember_preference.py:55-73 的 args docstring 已完整承载 canonical_key / polarity / strength / keywords / keys_to_supersede 逐字段说明 + 三原则 Integration/Resolution 逻辑。 → system_prompt 里 147-160 的字段详解属重复,可安全压缩。

改前(tool_policy 137-160,含三原则 + 7 字段逐条详解,约 24 行)#

改后(保留三原则判断 + 关键点,字段细节依赖工具 args;约 10 行)#

- remember_preference:把用户本轮明确表达的**跨会话仍成立的持久偏好**记进长期记忆。**非终结**,
  识别到就调、不必等收尾。写入前按**记忆合并三原则**判断(三选一):
  1. 保留(不写):只是对当前结果满意/附和(「这个不错」「就它了」)或本轮一次性约束
     (「这次预算 300」)——一律不写,它们不是跨会话取向。
  2. 新增(写、不传 keys_to_supersede):新的、与已有不冲突的持久取向。
  3. 取代(写、带 keys_to_supersede):与已有偏好冲突/翻转(「改成/不要了/换成」)——写新的并列出
     被取代的旧 key(从 `<user_long_term_preferences>` 查)。
  参数格式(canonical_key = `polarity:category:英文标识`、dislike 必给 keywords 作黑名单、strength
  仅对 dislike 有意义)详见工具入参说明;同一意思不管中英文都要生成相同 canonical_key。
plaintext

语义核对:三原则完整✅ canonical_key 归一✅ dislike 给 keywords✅ strength 语义✅ keys_to_supersede✅(细节转由工具 args 承载,不是删除)。省 ~8 行。 前置条件:确认工具 args description 覆盖等价信息后再删;否则只压措辞、保留字段点。


刀7 —— 空召回/不编造去重 🟡(P0 诚实红线,审查重点)#

改前(两处)#

  • <fork_protocol> 执行方 207-208:子视角”空召回松一档再搜一次、仍空如实返回、不编造”
  • <termination> 空召回硬路径 285-294:主视角”各平台汇集全空→shopping_summary 如实告知、不拿 category_insight 编造/背书”

改后#

  • 子视角:已并入刀1 的执行方通则(“空召回可松一档再搜一次,仍空如实返回,绝不编造”)
  • 主视角:<termination> 权威版完整保留(这是 P0,是主 loop 收尾诚实的核心,一字不弱化)
  • 仅去掉子/主之间的措辞重叠,省 ~4 行。红线语义零弱化。

刀2 —— workflow × tool_policy 工具触发条件去重 🟢#

改前(同一触发条件两处讲)#

  • price_compare:workflow 61「tasks 含 price_compare 时对候选调」 vs tool_policy 129「仅 tasks 含 price_compare 时调」
  • shipping_calc:workflow 63-65 vs tool_policy 130-131
  • category_insight 两场景:workflow 26-32 vs tool_policy 114-118
  • item_search 跨平台:workflow 42-46 vs tool_policy 119

改后#

  • <tool_policy> 留「工具能力 + 何时调」权威版(工具手册视角,不动)
  • <workflow>编排菜单逻辑(按 tasks 组合、跳过没要的、依赖顺序、fork 判断、收尾分界), 删掉与 tool_policy 逐字重复的”仅 tasks 含 X 时调”触发细则
  • 注意:workflow 条目里”对已汇集/定位的候选调”这类编排依赖语义要保留,只删纯触发条件重复
  • 省 ~10-12 行(清单原估 15,诚实下调——两块视角不同,重叠只在触发条件)

刀4 —— 收尾分界线「要不要甩商品卡」去重 🟢#

  • 改前:workflow 84-88 与 termination 270-271 逐点重复(有商品卡→shopping_summary,纯文字→chat_fallback)
  • 改后:权威版留 <termination>;workflow 里压成一句”收尾规则(终结工具怎么选)见 <termination>
  • 省 ~4 行

刀5 —— 跨平台「一次 fork / 5 平台 / 别重复拓宽」去重 🟢#

  • 改前:至少 5 处——workflow 42-46、workflow 关键纪律 96、tool_policy 119、fork_protocol 190-198、 constraints 304
  • 改后:<fork_protocol> 派发方留权威版(含 5 平台清单、写法);<constraints>一句红线 (“跨平台一律 parallel_dispatch 一次并行,禁止串行多次 item_search”);workflow / tool_policy 的 重复叮嘱删除或压成一句指向 fork_protocol
  • 省 ~6 行。 🟡 注意保留”汇集后不要对同品类再 fork 拓宽”这条防死循环语义(并进 fork_protocol)

刀8 —— 主动性纪律 vs ask_user 合并 🟢#

  • 改前:workflow 主动性纪律 90-93 与 tool_policy ask_user 164-165 都讲”品类宽直接搜、别停下反问、 只硬信息缺失才澄清”
  • 改后:合并到 <workflow> 主动性纪律一处;tool_policy 的 ask_user 条只留工具能力一句 + 指向
  • 省 ~3 行

刀9 —— 全局排比 / 括号补充 / “(见 xxx)” 精简 🟢#

  • 逐句精简冗长括号注释与重复语气,不动任何语义
  • 省 ~15-20 行

账目汇总#

省行安全度
1 两 protocol 执行方~11🟡
2 workflow×tool_policy~10-12🟢
3 item_search 双过滤~8🟡
4 收尾分界线~4🟢
5 跨平台一次 fork~6🟢
6 remember 参数~8🟡
7 空召回不编造~4🟡
8 主动性 vs ask_user~3🟢
9 全局措辞~15-20🟢
合计~69-76 行309 → ~233-240 行;token ~6.3k → ~4.7k

验证方式#

  1. pytest tests/test_m0_base.py tests/test_main_agent.py(保 6 个 XML 标签 + 偏好注入)
  2. 种子集 rubric 前后对照(scripts/eval/run_rubric.py),确认无退化
  3. 🟡 四刀(1/3/6/7)逐条核对语义无损后再提交

执行实况(本节为落地后追记,上文预览稿写于 P_t / 记忆管家落地之前,行号已过时)#

上面的预览稿针对旧 309 行版本;实际执行时文件已因「会话级短期记忆 P_t + 记忆管家 curator」 涨到 399 行,多出 <user_recent_history> / <session_preferences> / <memory_note> 注入块 与 curator prompt。故实际治理基于当前 399 行重新盘点,与预览稿有偏差,如实记录如下。

与预览稿的偏差#

  • 刀6(remember_preference 参数精简)作废remember_preference 已不在 system prompt 里 (偏好沉淀移交给会话结束后的记忆管家),此刀无对象。
  • 新增重复 A(记忆归属):「沉淀是记忆管家的事、你不写长期库」原在 <role> / forget_preference 注 / <memory_note> / <termination> 讲了 4 遍 → 收敛到 <memory_note> 权威、其余压成引用。🟡
  • 新增重复 B(定点隔离机制):「demands 无平台名→自动识别定点批次→web_search 门控互不拦」 原在 <workflow> + <target_protocol> 各一遍 → 只留 <target_protocol>。🟢
  • 新增重复 C(evaluate 诚实前提):「未库内定位不拿品类基准背书」<workflow> 精简 + 引用 <termination> 空召回硬路径权威版。🟡

其余刀 1/2/3/4/5/7/8/9 仍成立并已执行。

实际账目#

结果
system_prompt300 → 259 行(净 −41)
全文 diff−144 / +103(git diff --stat
token~6.3k → ~5.0k
护栏测试test_m0_base + test_main_agent45 项全过
语义无损自查16 条关键防线短语全部存活(≥1),P0 空召回硬路径一字未弱化

同质 fork 不破:工具集一件不减、无角色感知渲染、主/子共用同一份 prompt——只做块内 + 跨块去重。


工程加固轮(治理后追加:从垂直 Agent 工程实践审视)#

去重完成后,进一步从「垂直 Agent 工程实践」角度审视(非再精简——精简对延迟无益:实测 295s 里 96% 是 decode 解码,prefill 可忽略,继续切措辞是负 ROI)。识别并落地两处实缺

① 补输出语言约定(高 ROI,已落地)#

全球电商多语言场景,但 prompt 原先一句都没约定回复语言,纯靠 LLM 默认跟随,中文用户 + 英文商品名 + 英文 web_search 结果混杂时回复语言易漂移。<constraints> 补一条: 「用用户提问所用的语言回复;商品原文名 / 型号 / 品牌保留原文不译,必要时括注中文。」

② P0 诚实红线显式标注(纯标注不改语义,已落地)#

<termination> 标了 P0,但 <constraints> 原为平铺红线、无优先级。加优先级总纲 「P0(诚实红线)> 硬约束 > 质量要求,冲突/信息不足时永远保 P0」,并把「不编造商品/价格/ 运费/评分」提升为 【P0】、以指针引用 <termination> 空召回硬路径(不重复展开)。

③ cache 位置按 volatility 排序(分析后主动不做 → 后续以另一种方式落地,见文末追记)#

现状注入顺序 long_term(每用户) → recent_history(每用户) → session_preferences(每轮变),其后压 着 <memory_note>/<termination>/<constraints> ~70 行静态内容,隐式前缀缓存每轮在 session_preferences 处断开、这 70 行每轮重新 prefill。教科书做法是「最易变的挪到最末、静态块上提」。 当时不做的理由:(a) 与「硬约束放结尾」的位置敏感原则冲突;(b) 本项目 decode-bound,延迟收益≈0, 只省少量 input token;(c) 当前 cache_control 只打在对话历史(较旧区),system prompt 没有显式缓存 断点。

后续更新:没有按「挪到 system prompt 末尾」这个教科书方案做(会真撞上 (a) 的冲突),而是 发现 session_preferences(P_t)本质是「当轮状态」而非「系统级指令」,压根不该待在 system prompt 里——直接搬到当轮 human message 尾部,见文末「P_t 迁出 system prompt」一节,(a)(c) 两条 理由因此都不成立了;(b) 的判断仍然对(延迟收益≈0),这次做纯粹是为了让 prior_turns 的显式 cache_control 断点(M6 已实现)真正吃到跨轮命中,而不是每轮陪跑打空。

架构洞察(不改,存档备忘)#

同质 fork 令子 Agent 读整份 259 行(~150 行主流程编排它作为执行方用不上)是 token 税;但正因 完全同质、跨 fork 字节一致,DashScope 隐式前缀缓存可跨 5 个并行子 Agent 复用同一 system 前缀, 部分抵消。同质设计在缓存上是红利而非纯负担——「别给子 Agent 专用化裁剪」除架构一致性外还有 缓存复用的实利。

工程加固轮验证#

pytest tests/test_m0_base.py tests/test_main_agent.py 45 项全过;YAML 可解析、5 个必需标签齐、 回复语言 / 【P0】 均已入 system prompt。


P_t 迁出 system prompt(③ 的后续落地:不排序,搬家)#

背景#

上面 ③ 判断「排序」这个教科书方案会撞上「硬约束放结尾」的位置敏感原则,主动搁置。重新想这个 问题时发现:症结不是「session_preferences 排得不够靠后」,而是它根本不该出现在 system prompt 里——system prompt 是「本该跨轮稳定的指令」,P_t 是「这一轮才知道的会话状态」,两者混在一起, P_t 每轮一变就把它前面本该稳定复用的整段(含长期偏好、<constraints> 等静态指令)一起拖下水。 system prompt 在一次请求里天然排在 messages 数组最前面——只要它自己不逐轮变,messagesprior_turns(回填的历史摘要)打的那个 cache_control 断点(M6 已实现)才有意义;只要 P_t 还嵌 在 system prompt 里,这个断点年年打、年年空。

改法#

  • prompt/prompts.yml<session_preferences> 块去掉 {session_preferences} 占位符,改成一段 纯静态说明(更名 <session_constraints_policy>),告诉模型这类内容会包在 <session_constraints> 标签里跟着用户消息一起送达。
  • get_system_prompt() 去掉 session_preferences 形参——system prompt 现在只有「静态指令 + 长期 偏好 + 行为历史」,跟当轮/当次会话状态彻底解耦。
  • main_agent.run_agent() 新增 _inject_session_constraints(query, pt):P_t 非空时包成 <session_constraints>...</session_constraints> 拼在本轮 query 前面,作为 payload["messages"] 里那条 human message 的内容;P_t 为空(会话刚开始)则不加标签,不产生占位噪音。

代价:P_t 现在离「当轮 query」只有一层标签之隔,模型看它的注意力位置变了——但从 app.memory.curator/item_picker 的机制性强制执行(硬 dislike 进 exclude、预算兜底)来看,P_t 本 来就不是「靠模型自己记住」的唯一保险,这层风险可控。

验证(真实 DashScope 调用,非单测断言结构对不对)#

同一 thread_id 连续跑两轮真实购物对话(deepseek-v4-flash),在 BaseChatModel.ainvoke 层读 LangChain 解析好的 usage_metadata.input_token_details.cache_read

input_tokenscache_read命中率
第一轮开局第一次调用124281228898.9%
第二轮开局第一次调用(跨过一整轮,P_t 从空变非空)130651228894.1%

两轮 cache_read 绝对值相同(12288)——system prompt 静态前缀跨轮字节不变,prior_turns 断点终 于有稳定前缀可打。落盘核对 pt.json 确认第二轮开局时 P_t 确实非空(不是「碰巧没测出问题」)。

顺带对照跑了一版登录用户场景:第二轮 cache_read 掉到 5632(明显低于 12288),排查是 long_term_preferences(system prompt 中段,<constraints> 之前)跨轮可能因记忆管家写入新长期 偏好而变化,把它之后的静态段连累一起失效——这是另一个已知但尚未处理的 volatility 源,跟这次 P_t 的改动无关,暂不动(优先级低于本次修复,留作后续)。

结论#

③ 当初否决的是「排序」,不是「不做」;换成「搬家」之后,位置敏感原则不用碰、cache_control 显式断点也不用现在就打,(a)(c) 两条否决理由同时失效,改动量反而更小。