面试知识库

02 · 上下文与记忆#

简历原话:上下文与记忆:上下文按稳定性分层,静态内容前置,20 轮长会话前缀缓存命中率 93%;业务流程以 Skill 形式按意图加载;长期记忆分三类存储,由模型显式保存和回合后小模型抽取两路写入,写入前统一过滤 PII。

30 秒口述版#

ShoppingX 是单模型单环的 Agent,一轮购物任务主环要调 3~5 次模型,每次都把 system prompt、十几个工具定义和整段历史重新发一遍,所以输入 token 的大头是「重复内容」。我做的第一件事是把上下文按稳定性分层:角色、流程、工具定义、Skill 目录这些全天不变的放最前面,会话历史只追加不改写,每轮都会变的平台设置、行为历史、长期记忆一律放在最后,而且注入的东西要跟着会话状态存下来,不能只出现在当轮。这样 20 轮的长会话里,前缀缓存命中率做到 93%。第二件是把套装规划、到手价、订单售后这些业务流程拆成 7 个 Skill,system prompt 里只留一行描述,模型按意图判断要用时再读正文。第三件是长期记忆:按「硬约束 / 偏好 / 身份背景」三类存,模型在对话里可以显式保存,回合结束后还有一个小模型读原话补抽,两路都过同一道 PII 过滤门。

背景与问题#

1. 输入 token 大部分是重复的,但缓存命中不上。 主环每次模型调用的输入 = system prompt + 工具 schema + 历史消息 + 本轮内容。一轮 3~5 次调用、一个会话十几轮下来,同一段 system prompt 会被发几十遍。供应商(DashScope 的 OpenAI 兼容接口)有隐式前缀缓存:只要这次请求的开头字节和之前某次请求完全一样,这段就按缓存价计费、也不用重新做 prefill。问题是早期实现里前缀经常在很靠前的位置就变了:

  • 长期偏好和最近搜索历史拼在 system prompt 最前面。这两样每轮都可能变,一变,后面整段 system prompt 和全部历史都算「新内容」。
  • 控制面的一次性纠正提示(格式问题、漂移提醒)只塞进当轮请求,没有存进会话状态。第 N 轮请求尾部有这条,第 N+1 轮就没了,第 N+1 轮的请求不再是第 N 轮的「字节延伸」,缓存链从这里断开。某些坏 case 里命中率一直卡在 system 段长度附近,只有 31.6%。
  • 早期试过把 cache_control 标记跟着「倒数第 K 个工具结果」走,标记每轮往后挪。打标记会把消息的 content 从字符串改写成块列表,于是前缀字节每轮都在变,第二轮起就接不上。

2. 业务流程全写在 system prompt 里,越写越长、互相打架。 套装的预算分摊、跨境到手价口径、下单确认卡的顺序、图搜的措辞边界、记忆怎么用……每类活都有一套打法。全写进 system prompt,每轮都要为所有流程付 token,而一轮真正用到的通常只有一两类;规则多了还会互相覆盖,比如「单品直搜」和「多约束先核预算」的收尾要求就不一样。

3. 长期记忆「写得进去、用不对」。 早期的偏好模型是「极性 + 品类域 + slug」,由机制按品类域把长期偏好变成检索硬过滤。实际问题:

  • planner 判错品类域时,「不吃坚果」这种跨品类硬规则整轮静默消失;
  • 同一件事被换个说法写成新条目,中文判重按空格切词等于没判,注入块很快被同义句刷满;
  • 用户在对话里顺口说的手机号、邮箱有可能被抽取模型当「事实」记进长期库。

做法与取舍#

1. 上下文按稳定性分六层,越易变越靠后#

从请求开头往后排:

层内容变化频率
L0system prompt 主体:角色、工作流、工具选择策略、终止规则、硬约束 + 全部工具 schema只随发版变
L1Skill 目录:7 个内置 Skill + 用户个人 Skill 的名字和一句描述只随 Skill 增删变
L2装配期追加块:按本轮原话匹配到的已验证策略、待确认的订单卡多数轮为空
L3会话历史:前几轮的用户消息、工具调用、工具结果、回复只追加
L4本轮用户消息:启用平台、最近行为历史、参考图说明、用户原话每轮都变
L5planner 之后注入的长期记忆 system 消息每轮位置都在最新处
  • 为什么这样排:前缀缓存是逐字节匹配开头,第一个不同的字节之后全部重算。所以排序原则只有一条:任何会变的东西都不能出现在不变的东西前面。
  • L4 为什么放用户消息里、不放 system prompt:平台设置和行为历史每轮都可能变,放进 system prompt 会连带 L0~L3 一起失效;放在本轮用户消息里,它处在「本来就是新内容」的位置,不额外损失。
  • L5 为什么在 planner 之后注入:记忆要按本轮意图挑(见第 5 点),planner 跑完才知道这轮在买什么;而且它是 system 角色,放在最新位置只影响这一轮的尾部。
  • 否掉的方案:把记忆放进 system prompt 并按用户做缓存分片——一个用户一份 system prompt,命中率在用户粒度上能保住,但同一用户换一条记忆就全量失效,而且跨用户完全不能共享 L0,不划算。

2. 注入纪律:注入的东西要跟着会话状态存下来,已发出去的字节不改写#

分层只解决了「排在哪」,缓存能不能接上还取决于「已经发出去的内容下一轮是否一字不差」。定了三条纪律:

  • 注入随状态存储:控制面在某一轮插入的提示(纠正、收线通告、记忆块),写进会话状态,下一轮照样出现在原来的位置。这样第 N+1 轮的请求一定是第 N 轮的延伸,最多断在新注入那一处,下一次调用就恢复。
  • 历史只追加:会话状态整份序列化存成一个 JSON,下一轮原样读回;不做「每轮重新渲染历史」或滑动窗口裁剪。
  • 注入文本对同一份数据字节稳定:比如行为历史固定「购买在前、搜索在后、组内新到旧」,同样的数据渲染出同样的字符串。
  • 否掉的方案:每轮按 token 预算滑动裁掉最旧的消息。它能控长度,但每轮都在改前缀的开头,等于每轮缓存全废。

3. 缓存标记只钉在 system 段,一个#

  • DashScope 是隐式缓存,实测带不带 cache_control 标记,第二次请求命中量一样。标记还继续打,因为成本为零,换 Anthropic 直连时它是必需的。
  • 标记只打在 system 那一条上,位置和形态永远不变;system 段不足 1024 token 时不打(写了也不缓存,白占额度);单请求不超过 4 个标记。
  • 否掉的方案:标记跟着压缩断点走,理由见背景第 1 点,打标记本身在改字节。

4. 长会话的体积控制:两层压缩,但都「一次削到位」#

  • 第一层零成本:工具结果总量超过阈值时,把最旧的工具返回换成一行占位,一次削到一半,而不是每轮削一点。一次削完,前缀断一次,之后又能稳定很多轮。
  • 第二层花钱:上下文占到窗口 80% 时,由框架调用模型写一份续写摘要,存进会话状态,之后的请求以「摘要 + 最近消息」开头。摘要是二手信息,这个损失是有意接受的。
  • 同一会话内模型分档只切 thinking 开关、不换模型名。实测只切 enable_thinking 不会打断前缀缓存,换模型名才会。

5. 业务流程以 Skill 形式按意图加载#

  • 机制:每个 Skill 是一个目录,里面一个 SKILL.md,frontmatter 写名字和一句描述,正文是这类活的完整打法。system prompt 末尾只挂所有 Skill 的「名字 + 描述」目录,另配一个只读的 Skill 工具,模型判断用得上才调它把正文读进来。成本模型是「目录常驻、正文按需」:7 个 Skill 常驻的只有几百 token 的描述,一千多字的正文只在触发那一轮进上下文。
  • 7 个内置 Skill:多约束搜索与送礼(search-discovery)、选购研究(purchase-research)、套装规划(bundle-planning)、订单与售后(order-care)、长期记忆(memory-personalization)、图搜(image-shopping)、到手价口径(cross-border-duty)。用户还可以在前端写自己的个人 Skill,存在 MySQL,以 my/ 前缀进同一个目录。
  • 意图分流表:system prompt 里有一张表,「这轮像哪一行就读哪份」。只有点名了具体商品的单品直搜不读 Skill。
  • 先读后做,串行:规定 Skill(...) 单独发一轮,读完正文再发检索。早期允许和第一条检索同批发,结果正文到的时候检索词已经发出去了,套装的预算分摊、先问还是先搜这类第一步的指导全部没用上。代价是首次读 Skill 的那一轮多一次模型调用(实测约 1.95s,这次调用前缀命中 90%)。
  • 本会话读过的不再读:正文已经在 L3 历史里了,再读是重复付费。
  • Skill 目录放 L1、策略块放 L2:目录只随文件变,是静态的;策略块按本轮原话匹配,是变的,所以目录在前。另一个约束是任务运行中不改 SKILL.md,改一个字节这一轮后续请求的 system prompt 就变了。
  • 否掉的方案:
    • 全写进 system prompt:每轮为所有流程付 token,规则互相覆盖。
    • 由 planner 输出意图字段、机制按字段预注入对应正文:更确定,但只能覆盖内置 Skill,用户自写的 Skill 没有对应字段;而且预注入是一条随意图变化的块,会插在前缀中间。最后选了最通用的「模型按描述自己读」。
    • 做成子 Agent:本项目只有一个主环(见 Agent 主循环那条),Skill 只是知识,不需要单独的执行体。

6. 长期记忆:一张事实表,三类,注入按类定优先级#

  • 数据模型:每条记忆是 key / value / category,key 就是身份(英文小写下划线,写主题不写值,如 material_avoid、default_ship_to),同 key 覆盖写、不合并。存在 MySQL 的 memory_facts 表,(user_id, key) 唯一。
  • 三类:
    • constraint 一直成立的硬规则(「对镍过敏」「只收欧盟境内发货」):每轮全量注入,不受条数上限。漏一条就会推荐用户明说过不要的东西。
    • preference 取向(「偏爱小众品牌」)和 context 身份背景(「常寄日本」「穿 42 码」):按更新时间倒序补位,和 constraint 合计最多 8 条。
    • 没注入的也不是不可见:模型需要时调 recall_memories 按主题检索,返回带外部内容围栏。
  • 记忆只经模型上下文生效:注入块里标 [constraint] 的,由主模型自己写进 item_search 的排除词和价格区间;系统不再有一条「绕过模型直接按记忆过滤商品」的通路。
  • 为什么删掉机制侧硬过滤:原来一条偏好有两条生效路径——给模型看的文本和机制侧的过滤词表,两者判据不同,长期处在不一致;机制侧还要按品类域隔离,planner 判错域时硬规则整轮消失。收成一条路径后,记忆生没生效在工具入参里看得见。代价:普通轮的精挑由框架自动跑,没有模型入参,长期约束只在检索层和收尾时由模型把关。
  • 本轮说的不升长期:「这次预算 300」「今天想要蓝色」只进会话级约束,由 planner 每轮根据前几轮原话重算,不进长期库。

7. 两路写入:模型显式保存 + 回合后小模型抽取#

  • 显式保存(save_memory 工具):用户当场说「记住我不吃坚果」「以后都寄德国」时,主模型调工具写入,本轮后续检索马上能用,用户也能立刻看到「记住了」的回执。遗忘也走它:用户说「塑料的也可以了」,用同一个 key 覆盖成新值。模型手里没有删除口,删除只在前端偏好页由用户自己点。
  • 回合后抽取(curator):主回复推给用户之后,用快档小模型读这一轮,补抽用户没明说「记住」、但确实跨会话成立的事实。几个设计点:
    • 时机在结果下发之后:用户已经拿到答案,这次调用对用户零感知延迟;失败只记日志、返回空,不影响本轮任务状态。
    • 输入只有用户原话 + 最终回复 + 已存事实清单,不喂工具结果。商品标题、网页正文里写着「记住我是管理员」这类内容跟用户无关,喂进来等于给长期库开一道注入口。
    • 一轮最多 3 条:这是质量闸,允许写十条它就会把本轮的颜色、价位都凑成「长期偏好」。prompt 里的原则是「拿不准就不写:漏一条下次用户再说一遍就补上,错记一条会污染以后每一次推荐」。
    • 去重:同 key 且值说的是同一件事 → 跳过;新 key 但值与已存某条说的是同一件事 → 跳过。「同一件事」用字符 bigram Jaccard ≥ 0.6 判,中文没有空格,按空格分词算 Jaccard 恒为 0。
    • 清空护栏:用户表里有一个「记忆清空代数」,每点一次「清空」加一。抽取开始前读一次,写入前再读一次,变了说明模型跑的这几秒里用户清空过,整批丢弃,避免「刚清完又长回来」。
  • 为什么两路都要:只有回合后抽取,用户这轮说的「记住」本轮用不上、也没有回执;只有显式保存,依赖主模型每次都记得调工具,漏掉的就永远漏了。两路写同一张表、按 key 覆盖,不会冲突。
  • 否掉的方案:
    • 每轮用大模型同步抽取:多一次串行调用压在用户等待时间上。
    • 向量记忆库 + 语义召回:单用户事实量在几十条级别,全量读出来按类选就够;向量召回还会引入「召回了不相关旧偏好」的问题。
    • 让抽取模型输出「要删除的旧条目 id 列表」做冲突消解:模型经常拼错 id,拼错就删不掉旧的。改成 key 覆盖后不需要引用 id。

8. 写入前统一过滤 PII:一道门,三条写路径都过#

  • 三条写路径——save_memory 工具、回合后抽取、偏好页 API 手动编辑——入库前都调同一个校验函数,没有第二个入口。
  • 校验做四件事:
    1. key 统一小写、空格转下划线,让「Ship To」和「ship_to」认成同一条;
    2. 剥掉外部内容围栏标记、压掉换行和连续空白、截长(key 64 字、value 200 字)。一条事实就是一行,留换行等于让被记住的内容能在注入块里伪造出新结构;
    3. key 或 value 命中任一 PII 模式就拒绝:连续 9 位以上数字(允许空格、横杠、点、括号分隔,覆盖卡号、证件号、手机号;日期、价格、尺码都短于这个长度不会误伤)、IBAN 形态的账户号、邮箱。部署时可以通过环境变量追加模式;
    4. category 不合法时退到 preference:分类错只是注入优先级低一档,比整条丢掉划算。
  • 被拒时错误消息和日志都不回显 value,只记 key:被拒的内容多半正是不该扩散的东西。回给模型的文案是固定一句「长期记忆只放偏好和长期规则,不放账号、卡号、证件或联系方式」,模型会把它转述给用户。
  • 为什么是正则而不是 NER / 大模型判断:这道门在三条路径上都同步执行,要求确定、零延迟、可单测。要拦的是「绝不该进长期库的标识符」,这类东西格式特征很强;语义层面的「这是不是隐私」由抽取 prompt 先排除一遍,正则是最后一道硬门。

流程图 / 架构图#

图 1:一次模型请求的上下文分层与缓存边界#

flowchart TB
    subgraph REQ["一次主环模型请求 · 从开头往后"]
        direction TB
        L0["L0 system 主体 + 工具 schema<br/>只随发版变 · 唯一的 cache_control 标记"]
        L1["L1 Skill 目录<br/>名字 + 一句描述"]
        L2["L2 装配期追加块<br/>匹配到的策略 / 待确认订单卡 · 多数轮为空"]
        L3["L3 会话历史<br/>从 session 状态读回 · 只追加"]
        L4["L4 本轮用户消息<br/>启用平台 + 行为历史 + 参考图 + 原话"]
        L5["L5 planner 后注入<br/>长期记忆 system 消息"]
        L0 --> L1 --> L2 --> L3 --> L4 --> L5
    end
    HIT["与上一次请求逐字节相同<br/>按缓存计费 · 跳过 prefill"]
    MISS["新内容 · 重新计算"]
    L3 -.-> HIT
    L4 -.-> MISS
    L5 -.-> MISS
    SAVE[("会话状态 JSON")]
    L5 -- "本轮结束随状态保存" --> SAVE
    L4 -- "本轮结束随状态保存" --> SAVE
    SAVE -- "下一轮读回, 变成 L3 的一部分" --> L3

要点:本轮新增的 L4/L5 在下一轮变成 L3 的尾部,所以第 N+1 轮请求是第 N 轮的延伸;只要注入不改写已发出的字节,缓存最多断在最新注入处。

图 2:一轮任务里 Skill 加载与记忆读写的时序#

sequenceDiagram
    participant U as 用户
    participant O as 编排层
    participant M as 主模型
    participant P as planner
    participant F as 事实库 MySQL
    participant C as curator 小模型

    U->>O: 本轮原话
    O->>O: 读会话状态 + 行为历史, 拼本轮用户消息
    O->>P: 开局预置 planner
    P-->>O: 结构化意图
    O->>F: 读全部事实
    F-->>O: constraint 全部 + 其余按新鲜度补到 8 条
    O->>M: 注入长期记忆 system 消息
    M->>M: 查意图分流表
    M->>O: Skill 读取 search-discovery
    O-->>M: 正文, 进入历史
    M->>O: 同轮多发 item_search, 把 constraint 写进排除词
    opt 用户说记住 X
        M->>O: save_memory key value category
        O->>O: PII 校验门
        O->>F: 按 key 覆盖写
    end
    M->>O: shopping_summary 收尾
    O-->>U: 推送清单
    O->>C: 用户原话 + 最终回复 + 已存事实
    C-->>O: 最多 3 条候选
    O->>O: PII 校验门 + bigram 去重 + 清空代数比对
    O->>F: 按 key 覆盖写
    O-->>U: 推送 记住了 X

图 3:一条候选事实从提出到入库#

flowchart LR
    A1["save_memory 工具"] --> G
    A2["回合后 curator"] --> G
    A3["偏好页 API"] --> G
    G{"统一校验门"}
    G -- "空值或命中 PII 正则" --> R["拒绝<br/>回固定文案 · 日志不含 value"]
    G -- "通过" --> N["规范化<br/>key 小写 · 剥围栏 · 压换行 · 截长"]
    N --> D{"仅 curator 路径<br/>同 key 同义或新 key 同义?"}
    D -- "是" --> S["跳过"]
    D -- "否" --> K{"清空代数变了?"}
    K -- "是" --> S2["整批丢弃"]
    K -- "否" --> W[("memory_facts<br/>user_id + key 覆盖写")]

数字怎么来的#

前缀缓存命中率 93%

  • 测什么:同一个会话里所有模型请求的加权前缀缓存命中率 = Σ 缓存命中的输入 token / Σ 输入 token。两个数都取自供应商响应里的 usage 字段(prompt_tokens 和其中的 cached_tokens),不是自己估的。
  • 怎么测:写了一个探针脚本,在模型客户端出口挂钩子,每次调用把 usage 按「第几轮、第几次调用、是不是主环」逐条记下来。然后在一个会话里连续跑 20 轮真实对话,话题故意跨品类走一遍:双肩包 → 旅行颈枕 → 行李绑带 → 一套旅行装备 → 降噪耳机 → 最后让它总结。召回连真实的 Qdrant 库,模型用线上同款配置。
  • 结果:20 轮合计输入约 371 万 token,缓存命中约 347 万,全部调用加权 93.4%;只算主环 93.7%。其中有一轮触发了「中途换品类后反复检索」的异常(那个死循环后来修掉了,见 Agent 主循环那条),剔除这一轮后主环是 93.0%,简历写的就是这个数。
  • 分段看:第 1 轮 50.6%(只有 L0/L1 是热的,其余全新);第 1~5 轮平均 88.4%;第 4 轮起基本稳定在 92%97%;第 620 轮 93.4%。
  • 对比基线:
    • 同一套代码在大量短会话上(395 轮,多数会话只有 12 轮)加权是 76.4%。差距主要来自结构:一轮只有 23 次主环调用,新内容只被复读 1~2 次就结束了,短会话的理论上限约 85%。所以简历特意写「20 轮长会话」,不和短会话混算。
    • 分层和注入纪律做之前,控制面一次性注入只进当轮视图,坏 case 的命中率卡在 31.6%,修完同一条 case 重跑到 67.5%,之后再做分层和串行 Skill 才到现在的水平。
  • 为什么不是 100%:每轮至少有 L4 本轮消息、L5 记忆块、以及上一次调用之后新增的工具结果是新内容;第一轮更是只有静态段能命中。

Skill 串行的代价

  • 同一批评测里对比「Skill 与检索同批发」和「先读后做」:首次读 Skill 的那一轮多一次模型调用,这次调用耗时约 1.95s,前缀命中 90%(因为它的输入就是上一次调用加一行 Skill 调用)。

追问 Q&A#

Q1:前缀缓存命中率是谁算的?会不会是你自己估的? 是供应商返回的 usage 里直接给的:每次请求的 prompt_tokens 和其中命中缓存的 cached_tokens。我在模型客户端出口挂了个钩子逐条记,最后按 token 加权求和相除。没有用「按字符估算相同前缀长度」这种自算口径,那种算法会高估,因为供应商的缓存按块粒度对齐,零头部分不算命中。

Q2:你说打 cache_control 标记没用,那命中率到底是怎么来的? DashScope 的 OpenAI 兼容接口是隐式前缀缓存,我做过对照:带标记和不带标记两组请求,第二次命中量一样。所以命中率来自「前缀字节逐轮不变」,是分层和注入纪律挣来的。标记我仍然打在 system 段上,因为零成本,而且换 Anthropic 这类显式缓存的供应商时是必需的。反过来,第一版把标记跟着压缩断点走,标记本身改写了消息格式,反而把缓存打断了,这是实测后改掉的。

Q3:93% 是长会话,那大部分用户只聊一两轮,这个数有代表性吗? 没有全局代表性,所以简历写的是「20 轮长会话」。短会话我也测过,395 轮加权 76.4%。短会话低的原因是结构性的:一轮只有 2~3 次主环调用,新内容被复读的次数少,理论上限大概 85%。长会话才是分层收益最明显的场景,也是成本最高的场景,一个会话十几轮时重复的 system prompt 和历史是输入 token 的绝对大头。

Q4:长期记忆为什么不放进 system prompt?放在最后,模型会不会不重视? 放进 system prompt 就在前缀最前面,任何一条记忆变动都让整段 system 和后面全部历史失效;而且不同用户的 system 不一样,L0 就不能跨用户共享了。位置上它其实是在最新处,离模型要做决策的地方最近,注意力上不吃亏。为了让模型当回事,块里给每条标了 [constraint],紧跟一句说明「标 constraint 的本轮必须遵守,要把它写进 item_search 的排除词,系统不会替你塞」。是否生效在工具入参里看得见,评测时可以直接检查。

Q5:会话越来越长,总要压缩,压缩不是会把前缀打断吗? 会,所以两层压缩都是「一次削到位」:工具结果超阈值时一次把最旧的一半换成占位,框架摘要在上下文占到窗口 80% 时才触发。这两件事都会让前缀断一次,但断完之后又能稳定很多轮。否掉的是每轮滑动窗口裁剪,那是每轮都改前缀开头。

Q6:Skill 靠模型看描述自己决定读不读,读错了或者不读怎么办? 三层应对。第一,description 是唯一的触发面,所以每条都写成「当用户说 …… 时读它;…… 时不用读」这种带正反例的句式。第二,system prompt 里有意图分流表,并明确写了「知道该搜什么不构成不读的理由」,堵最常见的偷懒。第三,用评测发现漏读:Rubric 评测里有执行规范项,比如套装轮没读 bundle-planning 导致没做预算分摊,会被扣分定位出来,然后改描述或分流表。我没有加机制强制读,因为强制只能覆盖内置 Skill,用户自己写的 Skill 没有对应的意图字段。

Q7:为什么要求 Skill 先单独读、再检索?多一次调用不是更慢吗? 慢约 1.95s,但早期同批发的问题更大:正文到的时候检索已经发出去了,而正文里恰恰写的是第一步怎么走,比如套装先按槽位分预算、送礼先问收礼人。等于读了个寂寞。这次多出来的调用前缀命中 90%,只多付一点解码时间。另外一个会话里同一个 Skill 只读一次,后面的轮次直接照着做。

Q8:回合后抽取用小模型,抽错了怎么办? 几道闸按顺序:prompt 只让它记「关于这个人的、下次还成立的」,并明确列了不合格的例子(本次预算、对结果的附和、商品标题里的东西);一轮最多 3 条;输入里不给工具结果,只给用户原话和最终回复;写入前过 PII 门和去重。真抽错了,用户在偏好页能看到每条的来源会话,可以直接删;用户在对话里改口,模型用同一个 key 覆盖。设计上宁可漏记,因为漏一条用户下次会再说,错记一条会影响以后每次推荐而且他归因不到。

Q9:显式保存和回合后抽取同时写同一个主题,会不会冲突? 不会。两路写同一张表,唯一约束是 (user_id, key),都是覆盖写。抽取模型的输入里包含「已存事实清单」,prompt 要求更新已有主题时原样复用那条的 key;它看到本轮 save_memory 已经写过的那条,要么判为同义跳过,要么用同 key 覆盖成更新的说法。最坏情况是换了个 key 写了同义句,这时 bigram Jaccard 去重会拦下来。

Q10:PII 用正则过滤,太粗了吧?地址、姓名拦不住。 这道门定位是「最后一道确定性的硬门」,拦格式特征强、泄露代价高的标识符:卡号、证件号、手机号、IBAN、邮箱。它在三条写路径上同步执行,要求零延迟、结果确定、能单测,NER 或大模型判断都满足不了。地址门牌、姓名这类语义层面的隐私,第一道在抽取 prompt 里就排除了,显式保存那一路则是用户自己说「记住」。另外这个门支持部署时通过环境变量追加正则,不改代码。如果要更严,我会在门里加一层 Luhn 校验和手机号号段规则减少误报,而不是换成模型判断。

Q11:如果用户点了「清空记忆」,而回合后抽取正在跑呢? 用户表上有一个清空代数字段,每清空一次加一。抽取开始时先读代数、再读已存事实,写入前再读一次代数,不一致就整批丢弃。读的顺序有讲究:先取代数再读事实,这样即使清空发生在两次读之间,末尾比对也能发现。反过来取就留了一道缝。

Q12:为什么不用向量库做记忆检索,现在很多记忆框架都这么做。 量级不需要。一个用户的长期事实是几十条,全部读出来按类挑,constraint 全进、其余按新鲜度补到 8 条,一次主键查询就够。向量召回会带来「召回了一条语义相近但不相关的旧偏好」的问题,而记忆出错的表现是推荐被悄悄做反,很难排查。剩下没注入的,模型需要时调 recall_memories 按主题做子串匹配,中文不按空格切词。

相关八股#

1. 前缀缓存(Prefix Caching)的原理是什么?为什么只能复用「前缀」?

  • Transformer 推理分 prefill 和 decode 两段。prefill 把整段输入算一遍,得到每层每个 token 的 K、V,存成 KV Cache;decode 每生成一个 token 只算新 token,并复用之前的 KV。
  • 因果注意力下,第 i 个 token 的 K/V 只依赖前 i 个 token。所以两个请求只要开头 n 个 token 完全相同,这 n 个 token 的 KV 就完全相同,可以直接复用;中间任何一个 token 不同,它之后的所有 KV 都会变,只能从那里重算。
  • 工程实现一般按固定大小的块(如 vLLM 每块 16 个 token)对 token 序列做链式哈希,块哈希 = hash(前一块哈希, 本块 token),命中就复用物理块。所以命中长度按块对齐。
  • 关联本项目:上下文分层的唯一依据就是「第一个不同 token 之后全部重算」,所以易变内容全放在最后。

2. 显式缓存(Anthropic cache_control)和隐式缓存(OpenAI / DashScope 自动缓存)有什么区别?

  • 显式:由调用方在消息上标断点,供应商只缓存断点之前的前缀;有最少 token 门槛(如 1024),单请求断点数有上限(Anthropic 是 4 个),缓存写入按更高价计费、读按低价计费,默认 TTL 约 5 分钟,可付费延长。
  • 隐式:供应商自动对请求前缀做缓存匹配,调用方不需要标,命中部分按折扣价计费,门槛和 TTL 由供应商定。
  • 共同点:都要求前缀逐字节一致,都受 tools 定义、system 内容、图片等影响。
  • 关联本项目:当前网关是隐式缓存,标记只打在 system 段,为切换显式缓存供应商留好位置。

3. 为什么说「system prompt + 工具定义」的字节稳定性比长度更重要?

  • 长度决定一次命中能省多少,稳定性决定能不能命中。一个每轮都在变的 2k token 前缀,收益是 0;一个稳定的 10k 前缀,从第二次调用起每次都省。
  • 常见破坏稳定性的写法:在 system 里放当前时间、用户名、随机 few-shot;工具列表按字典无序遍历生成;JSON 序列化字段顺序不固定;每轮重新渲染历史。
  • 关联本项目:行为历史的渲染固定排序、注入随状态存储,都是为了同一份数据得到同一份字节。

4. 长上下文的压缩策略有哪些?各自代价?

  • 截断 / 滑动窗口:最简单,直接丢最旧的消息,代价是信息丢失,且每轮改前缀开头,缓存全废。
  • 工具结果清理:把旧的工具返回换成占位,保留调用骨架,信息损失集中在「已经用过的原始数据」上。
  • 摘要压缩:让模型把旧对话写成摘要替换原文,保留要点,代价是一次额外调用,摘要是二手信息,可能丢细节或引入错误。
  • 外部存储 + 检索:把旧内容放到外部,需要时检索回来(MemGPT 类分页思路),代价是检索可能漏。
  • 关联本项目:先做零成本的工具结果清理,再在 80% 阈值时交框架摘要,两者都一次削到位,减少打断前缀的次数。

5. 「Lost in the Middle」是什么?对上下文布局有什么启发?

  • 研究发现长上下文中,模型对开头和结尾的信息利用得最好,中间的信息容易被忽略,呈 U 形曲线。
  • 启发:关键指令放开头(system)或紧贴决策点的结尾;硬约束放在 prompt 末尾;动态的关键信息放在最新消息里。
  • 关联本项目:稳定的规则放开头(也正好利于缓存),长期记忆的 constraint 放在 planner 之后的最新位置,system prompt 的硬约束段放在结尾。

6. Agent 的记忆一般怎么分类?短期和长期各自存哪?

  • 常见分法:工作记忆(当前上下文窗口)、短期 / 会话记忆(本会话状态,跨轮但不跨会话)、长期记忆(跨会话)。长期记忆又常分为语义记忆(事实、偏好)、情景记忆(过去发生的事件)、程序性记忆(怎么做事的规则或技能)。
  • 存储上:会话状态常用 KV 或文档存储;长期事实用关系库或向量库;程序性记忆常以 prompt 片段、Skill、few-shot 的形式存在。
  • 关联本项目:会话状态存为一个 JSON;长期事实是 MySQL 里 constraint / preference / context 三类;行为历史算情景记忆;Skill 就是程序性记忆,按需加载。

7. Jaccard 相似度是什么?字符 n-gram 和分词有什么区别?大规模怎么算?

  • Jaccard(A, B) = |A ∩ B| / |A ∪ B|,取值 0~1,适合集合间的重合度。
  • 对文本要先转成集合:按词切依赖分词,中文不能按空格切;字符 n-gram(如 bigram)不依赖分词,对错字和措辞变化更鲁棒,代价是对短文本区分度偏低。
  • 大规模去重用 MinHash 近似 Jaccard:对集合做 k 个哈希取最小值,两个签名相同位置相等的比例是 Jaccard 的无偏估计;再配 LSH 分桶,只比较落进同桶的候选。
  • 关联本项目:单用户事实只有几十条,直接两两算字符 bigram Jaccard,阈值 0.6。

8. 怎么检测和过滤 PII?正则、NER、大模型各适合什么?

  • 正则:适合格式固定的标识符(手机号、身份证号、银行卡号、邮箱、IBAN),确定、快、可测;可配合校验位减少误报,如银行卡的 Luhn 算法、身份证最后一位的加权校验。
  • NER 模型:适合姓名、地址、机构名这类没有固定格式的实体,有漏报误报,需要评估召回率。
  • 大模型判断:能理解语境(「我住在 X 小区」),但慢、贵、不确定,适合离线审查不适合同步写入门。
  • 工程上常见做法是分层:前置 prompt 或策略约束 + 同步正则硬门 + 离线抽检。存储侧再加最小化原则、保留期和用户可删除。
  • 关联本项目:写入门用正则三条 + 可配置追加,语义隐私由抽取 prompt 先排除,被拒内容不进日志,用户可在偏好页删除任意一条。

9. Prompt 注入对长期记忆有什么特殊风险?怎么防?

  • 风险:长期记忆是跨会话持久的,一次被注入的内容会在以后每个会话都生效,属于「持久化注入」。来源通常是网页、商品描述等外部内容被当成用户说的话记下来。
  • 防法:抽取输入只放用户原话,不放工具结果;外部内容加围栏标记,写入时剥掉围栏并压掉换行防止伪造结构;写入走统一校验;用户可见可删。
  • 关联本项目:curator 不读工具结果,校验门剥围栏压换行,这是记忆模块和安全边界共用的规则。