<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
    <id>https://tianpan.co/zh/blog</id>
    <title>TianPan.co</title>
    <updated>2026-06-03T00:00:00.000Z</updated>
    <generator>https://github.com/jpmonette/feed</generator>
    <link rel="alternate" href="https://tianpan.co/zh/blog"/>
    <subtitle>Actionable essays, playbooks, and investor-grade memos on product, engineering leadership, and SaaS—so you ship faster and decide with conviction.</subtitle>
    <icon>https://tianpan.co/zh/favicon.ico</icon>
    <rights>All rights reserved 2026, Tian Pan</rights>
    <entry>
        <title type="html"><![CDATA[PII 脱敏哨兵如何悄然瓦解你的向量索引]]></title>
        <id>https://tianpan.co/zh/blog/2026-06-03-how-pii-redaction-sentinels-quietly-collapse-your-vector-index</id>
        <link href="https://tianpan.co/zh/blog/2026-06-03-how-pii-redaction-sentinels-quietly-collapse-your-vector-index"/>
        <updated>2026-06-03T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[将 PII 替换为哨兵令牌的隐私脱敏器可能会悄然主导你的嵌入几何结构，将每个脱敏后的文档坍缩到向量索引的单一枢纽中，并在基准测试无法监测到的地方降低检索质量。]]></summary>
        <content type="html"><![CDATA[<p>一位支持工程师调出了你的 RAG 控制台来调试一个投诉。客户问的是“我的账户现在看起来是什么样的”，得到的回答逻辑清晰且自信，但内容却完全是关于另一个人的账户。检索到的前三个数据块（chunks）全部属于其他客户。工程师针对最新的语料库快照运行了同样的查询，以排除索引延迟的可能性，结果相同。随后，她针对六个月前、即隐私脱敏器上线前的快照运行了查询。结果，正确客户的数据块排在了第一名。</p>
<p><img decoding="async" loading="lazy" src="https://opengraph-image.blockeden.xyz/api/og-tianpan-co?title=PII%20%E8%84%B1%E6%95%8F%E5%93%A8%E5%85%B5%E5%A6%82%E4%BD%95%E6%82%84%E7%84%B6%E7%93%A6%E8%A7%A3%E4%BD%A0%E7%9A%84%E5%90%91%E9%87%8F%E7%B4%A2%E5%BC%95" alt="" class="img_ev3q"></p>
<p>脱敏器的工作逻辑符合预期。每一个姓名都被替换为 <code>[NAME]</code>，每一封邮件都被替换为 <code>[EMAIL]</code>，每一个账号都被替换为 <code>[ACCOUNT]</code>。法务团队拥有清晰的审计追踪，安全团队也关闭了合规工单。但这两个团队都没考虑到的是，这些被安插在数百万份文档中相同句法插槽里的“哨兵”标记，被嵌入模型（embedding model）视为普通 Token —— 且这些 Token 之间的共现关系比任何真实内容都更可靠。脱敏器不仅删除了信息，它还添加了一个全新的、极其强烈的信号，即所有脱敏文档都共有这一特征，而其他文档则没有。</p>
<p>检索索引准确地执行了它的职责：寻找与查询最相似的文档。一旦有足够多的记录通过脱敏器，最相似的文档就不再是那些内容最相关的文档，而是那些包含脱敏产物最多的文档。Top-k 结果开始呈现出一种由其他客户脱敏记录组成的“均匀淤泥”状态，它们全部聚集在嵌入模型无意中学会识别的一个狭窄邻域内。系统在隐私处理上是对的，但在检索上是错的，而这两个失败其实是同一个失败在不同侧面的体现。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="没人负责的隐私与检索之间的接缝">没人负责的隐私与检索之间的接缝<a href="https://tianpan.co/zh/blog/2026-06-03-how-pii-redaction-sentinels-quietly-collapse-your-vector-index#%E6%B2%A1%E4%BA%BA%E8%B4%9F%E8%B4%A3%E7%9A%84%E9%9A%90%E7%A7%81%E4%B8%8E%E6%A3%80%E7%B4%A2%E4%B9%8B%E9%97%B4%E7%9A%84%E6%8E%A5%E7%BC%9D" class="hash-link" aria-label="没人负责的隐私与检索之间的接缝的直接链接" title="没人负责的隐私与检索之间的接缝的直接链接" translate="no">​</a></h2>
<p>这一失败之所以如此难以根除，是因为没有任何一个团队拥有端到端的可见性。隐私脱敏由安全或法务团队负责。他们根据针对 PII 检测基准的精确率（precision）和召回率（recall）来评估脱敏器：它是否捕捉到了姓名，是否放过了非姓名内容。基准测试结果显示达标，团队便继续推进其他工作。</p>
<p>检索质量由 AI 平台团队负责。他们在一个干净的测试集上评估嵌入器：包含已知相关文档的查询、NDCG@10、平均倒数排名（MRR）。基准测试显示没有任何退化，因为测试集是在脱敏器出现之前构建的，且在运行评估时脱敏器并未参与其中。</p>
<p>脱敏器对嵌入几何结构的二阶效应恰好处于这两个团队的真空地带。安全团队并不了解 Transformer 会如何处理 <code>[NAME]</code> 这个 Token。平台团队也不了解 <code>[NAME]</code> 在文档中出现的频率，或者它会与多少个其他哨兵标记相邻出现。在组织架构图和评估套件中，这个接缝都是不可见的。而其症状 —— 检索返回的记录除了脱敏历史外在语义上毫无共同之处 —— 仅在生产环境的运行时、针对真实客户查询时才会显现，而那时两个团队都没在关注。</p>
<p>有些团队会在支持工单量激增时发现这个问题。许多团队则根本发现不了，反而得出“嵌入模型在我们的数据上表现不佳”的结论，并开始寻找替代的嵌入器。当然，由于问题从未出在嵌入器身上，新的嵌入器在同样的脱敏语料库上也会表现出相同的行为。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="为什么嵌入模型会将哨兵标记视为强烈信号">为什么嵌入模型会将哨兵标记视为强烈信号<a href="https://tianpan.co/zh/blog/2026-06-03-how-pii-redaction-sentinels-quietly-collapse-your-vector-index#%E4%B8%BA%E4%BB%80%E4%B9%88%E5%B5%8C%E5%85%A5%E6%A8%A1%E5%9E%8B%E4%BC%9A%E5%B0%86%E5%93%A8%E5%85%B5%E6%A0%87%E8%AE%B0%E8%A7%86%E4%B8%BA%E5%BC%BA%E7%83%88%E4%BF%A1%E5%8F%B7" class="hash-link" aria-label="为什么嵌入模型会将哨兵标记视为强烈信号的直接链接" title="为什么嵌入模型会将哨兵标记视为强烈信号的直接链接" translate="no">​</a></h2>
<p>Transformer 嵌入模型的训练目标是生成向量，使得底层文本在语义相似时彼此接近。在实践中，“语义相似”意味着“倾向于出现在相似的语境中”。训练目标奖励模型捕捉可靠的共现模式，并将其嵌入为几何上的邻近性。</p>
<p>从模型的角度来看，脱敏哨兵是一个极其可靠的 Token。<code>[NAME]</code> 经常出现在 <code>[EMAIL]</code> 旁边。<code>[ACCOUNT]</code> 经常出现在 <code>[NAME]</code> 附近。它们周围的短语也被脱敏器模板化了 —— “客户 <code>[NAME]</code>，邮箱为 <code>[EMAIL]</code>，拥有账号 <code>[ACCOUNT]</code>” 这种模式在数百万条记录中以机械般的一致性重复出现。模型捕捉到了这一点，并学到了一个强烈的“这是一条脱敏后的客户记录”的表示。这种表示在几何上比语料库中任何实际的语义簇都要紧密，因为实际的语义簇比脱敏器的输出更嘈杂、更少模板化。</p>
<p>其结果就是高维空间研究中所称的“枢纽性”（hubness） —— 嵌入空间中的一小块区域，最近邻搜索总是返回这里。枢纽性是高维空间最近邻检索中一个被深入研究的病态现象：少数点变得与绝大部分查询都接近，检索质量随之下降，因为这些点被反复返回，而语义相关的点则被挤出了 Top-k。脱敏产物在功能上就是你注入到自己语料库中的一个诱发枢纽性的特征。</p>
<p>更糟糕的是，查询通常是不脱敏的。用户问“我的账户看起来是什么样的”时输入的是未脱敏的问题。查询向量由嵌入器根据普通语义内容定位。而语料库向量则由嵌入器根据普通语义内容加上脱敏器产生的一堆人造共现关系共同定位。这种不对称性意味着，本应匹配真实记录的查询会被拉向脱敏聚类区，因为那个聚类既稠密又处于核心位置，而你的真实记录则不然。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="你必须发明的审计手段">你必须发明的审计手段<a href="https://tianpan.co/zh/blog/2026-06-03-how-pii-redaction-sentinels-quietly-collapse-your-vector-index#%E4%BD%A0%E5%BF%85%E9%A1%BB%E5%8F%91%E6%98%8E%E7%9A%84%E5%AE%A1%E8%AE%A1%E6%89%8B%E6%AE%B5" class="hash-link" aria-label="你必须发明的审计手段的直接链接" title="你必须发明的审计手段的直接链接" translate="no">​</a></h2>
<p>你的技术栈中没有任何标准的、可观测的指标会告诉你，你的前 1 名结果中 80% 是脱敏伪影（redaction-artifact），只有 20% 是语义内容。向量数据库报告的是延迟、针对已知集合的召回率和存储利用率。嵌入流水线报告的是吞吐量和维度。检索评估报告的是针对基准测试的 NDCG，而这些基准在设计时从未考虑过脱敏占位符（redactor sentinels）。</p>
<p>你必须自己构建审计手段，而负责构建它的团队，应该是最接近客户投诉的那个团队。一个可行的起点如下：</p>
<ul>
<li class="">抽样几百个生产环境查询，并获取每个查询的前 k 个结果。</li>
<li class="">对于每个检索到的块，统计已知占位符模式（<code>[NAME]</code>、<code>[EMAIL]</code>、<code>[ACCOUNT]</code> 以及你的脱敏工具使用的任何自定义模式）的密度。</li>
<li class="">将该密度与整个语料库的平均密度进行比较。</li>
<li class="">如果前 k 个块的占位符密度系统性地高于语料库的随机样本，那么你就存在伪影污染——索引正在根据脱敏词汇而不是你的内容进行排序。</li>
</ul>
<p>第二个更精确的测试：选取一个脱敏块和一个未脱敏块，且你已知两者描述的是同一个真实的客户事实。对两者进行嵌入。测量距离。然后测量描述完全无关客户事实的两个脱敏块之间的距离。如果这对“无关但都经过脱敏”的块比“内容相同但脱敏状态不同”的块更接近，那么你就证明了嵌入模型将脱敏状态视为比内容更强的信号。这就是问题的本质。一旦你能展示这一点，隐私团队和平台团队终于会明白他们面对的是同一个 Bug。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="消除差距的模式">消除差距的模式<a href="https://tianpan.co/zh/blog/2026-06-03-how-pii-redaction-sentinels-quietly-collapse-your-vector-index#%E6%B6%88%E9%99%A4%E5%B7%AE%E8%B7%9D%E7%9A%84%E6%A8%A1%E5%BC%8F" class="hash-link" aria-label="消除差距的模式的直接链接" title="消除差距的模式的直接链接" translate="no">​</a></h2>
<p>一旦问题被命名，修复方案就会根据三个不同的时间维度展开。</p>
<p>最直接的修复方案是让脱敏工具产生的占位符不要在 Token 空间中全部冲突。不要为每次脱敏都使用单一的 <code>[NAME]</code>，而是将原始值（使用安全团队持有的密钥）哈希成一个由大量看起来合理但无意义的占位符组成的词汇表中的 Token——例如 <code>[NAME-7a2c]</code>、<code>[NAME-d191]</code> 等。嵌入模型现在会在这些位置看到多样化的 Token，模板化的共现模式会消失，枢纽现象（hubness）也会随之瓦解。隐私属性得以保留，因为在没有密钥的情况下哈希是不可逆的。代价是稍大的 Token 词汇表和需要携带哈希依赖的脱敏工具。好处是，向量索引的几何结构重新回到了对意义的衡量上。</p>
<p>更谨慎的长期修复方案是嵌入感知脱敏（embedding-aware redaction）。在部署新的脱敏工具或新的占位符方案之前，运行合成评估，测量建议的占位符对聚类的影响——嵌入语料库中具有代表性的一层（无论是否有脱敏），测量成对距离分布的变化和枢纽统计数据的变化，并像拦截 PII 召回率不合格的部署一样，根据这些数据对部署进行拦截。这使脱敏工具成为检索指标的一等公民，这是永久打破组织架构隔阂的唯一方法。</p>
<p>检索侧的缓解措施是最容易添加但也最容易出错的。你可以降低那些因占位符密度而获得高分的匹配项的权重，方法可以是后置过滤前 k 个结果，或者训练一个专门见过脱敏文本的小型重排序器（reranker）。风险在于，你可能也会降低用户真正需要的、合理脱敏的记录的权重。一个询问自己账户的用户确实希望检索到自己被脱敏的记录。重排序器必须学会区分“脱敏是其匹配的原因之一”和“脱敏与匹配无关”，这是一个真正的难题，而“直接惩罚占位符”无法解决这个问题。请将检索侧的修复视为短期的“止血带”，而非长久之计。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="这种现象的普遍性">这种现象的普遍性<a href="https://tianpan.co/zh/blog/2026-06-03-how-pii-redaction-sentinels-quietly-collapse-your-vector-index#%E8%BF%99%E7%A7%8D%E7%8E%B0%E8%B1%A1%E7%9A%84%E6%99%AE%E9%81%8D%E6%80%A7" class="hash-link" aria-label="这种现象的普遍性的直接链接" title="这种现象的普遍性的直接链接" translate="no">​</a></h2>
<p>PII 脱敏只是一个更广泛模式的实例，只要嵌入器上游的流水线组件产生了结构性重复输出，这种模式就会出现。来自 CMS 的模板化样板内容会产生这种情况。PDF 中自动生成的页眉页脚文本会产生这种情况。总是以“本文讨论了”开头的摘要步骤也会产生这种情况。任何将一致的、低熵模式注入到语料库大部分内容中的行为，都是潜在的枢纽来源，嵌入模型会将其视为场景中最强的特征。</p>
<p>这里的教训不是“害怕脱敏”或“停止清理数据”。教训是，嵌入模型是一种敏感仪器，它会对输入中出现最稳定的内容做出反应，而“出现最稳定”的内容很少等同于“信息量最大”的内容。嵌入器上游的每个流水线阶段，无论负责该阶段的团队是否意识到，都是检索质量的参与者。能够交付持久 RAG 系统的团队，是那些将检索几何结构视为共同关注点、像审计延迟和成本一样审计它、并像数据库团队检查索引膨胀一样检查嵌入空间伪影的团队。</p>
<p>那个账户被调换的客户，理应得到比一个“自信地检索出错误记录”的系统更好的服务。修复方法不是换一个更聪明的嵌入器。修复方法是意识到脱敏工具已经变成了比内容更响亮的信号，并为两个团队提供一种共同观察这一现象的方式。</p>]]></content>
        <author>
            <name>Tian Pan</name>
            <uri>https://tianpan.co</uri>
        </author>
        <category label="rag" term="rag"/>
        <category label="embeddings" term="embeddings"/>
        <category label="privacy" term="privacy"/>
        <category label="retrieval" term="retrieval"/>
        <category label="vector-search" term="vector-search"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[MCP 工具列表在会话中途增加，你的智能体调用了一个它从未被告知过的工具]]></title>
        <id>https://tianpan.co/zh/blog/2026-06-03-mcp-tool-list-grew-mid-session-hallucination</id>
        <link href="https://tianpan.co/zh/blog/2026-06-03-mcp-tool-list-grew-mid-session-hallucination"/>
        <updated>2026-06-03T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[在同一会话的两次调用之间，MCP 服务端可以增加其工具列表，而智能体的下一次选择可能会落在一个客户端从未被告知过的工具上 —— 这是一种能够转化为真实操作的幻觉。]]></summary>
        <content type="html"><![CDATA[<p>一场安全事件回顾以一个团队无法回答的问题开始：智能体是如何知道它刚刚调用的工具名称的？审计追踪显示了一个 <code>tools/call</code> 请求，但该工具的名称并未出现在 harness 记录的任何 <code>tools/list</code> 响应中。MCP 服务器欣然接受并执行了该调用。在事后分析中，当被要求解释工具名称来源时，模型给不出答案，因为根本没有答案 —— 它猜中了，而且这个猜测恰好命中了一个真实的操作。</p>
<p><img decoding="async" loading="lazy" src="https://opengraph-image.blockeden.xyz/api/og-tianpan-co?title=MCP%20%E5%B7%A5%E5%85%B7%E5%88%97%E8%A1%A8%E5%9C%A8%E4%BC%9A%E8%AF%9D%E4%B8%AD%E9%80%94%E5%A2%9E%E5%8A%A0%EF%BC%8C%E4%BD%A0%E7%9A%84%E6%99%BA%E8%83%BD%E4%BD%93%E8%B0%83%E7%94%A8%E4%BA%86%E4%B8%80%E4%B8%AA%E5%AE%83%E4%BB%8E%E6%9C%AA%E8%A2%AB%E5%91%8A%E7%9F%A5%E8%BF%87%E7%9A%84%E5%B7%A5%E5%85%B7" alt="" class="img_ev3q"></p>
<p>这是两个在理论上看起来兼容的假设之间产生的失效模式。客户端将工具列表视为一份契约，界定了它被授予的权限范围。服务器则将工具列表视为当前可用工具的快照，可以随着环境的变化自由增长。在这两种观点之间，LLM 是一座不知道二者差异的桥梁。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="mcp-助长的仅列表一次习惯">MCP 助长的“仅列表一次”习惯<a href="https://tianpan.co/zh/blog/2026-06-03-mcp-tool-list-grew-mid-session-hallucination#mcp-%E5%8A%A9%E9%95%BF%E7%9A%84%E4%BB%85%E5%88%97%E8%A1%A8%E4%B8%80%E6%AC%A1%E4%B9%A0%E6%83%AF" class="hash-link" aria-label="MCP 助长的“仅列表一次”习惯的直接链接" title="MCP ��助长的“仅列表一次”习惯的直接链接" translate="no">​</a></h2>
<p>大多数智能体 harness 在每个会话中仅调用一次 <code>tools/list</code>。原因很务实。工具描述通常很大；将它们序列化到每个提示词中会消耗大量 token，成本昂贵。在单次对话中，工具列表很少发生变化，因此缓存它是显而易见的优化方案。几个流行的客户端 SDK 在发布时都默认设置了 <code>cache_tools_list=True</code>，大多数生产环境中的智能体也保留了这一设置。</p>
<p>MCP 规范承认列表可能会发生变化。声明了 <code>listChanged</code> 能力的服务器应在可用工具集发生变化时发出 <code>notifications/tools/list_changed</code>，而收到通知的客户端应当重新获取。协议图示看起来很整洁：发现、调用、变更通知、重新发现。</p>
<p>图示中没有显示的是，当服务器添加了工具而客户端没有重新获取时会发生什么。也许客户端正处于回合之间，而通知在模型调用期间到达。也许 harness 是一个无状态的包装器，它忽略了通知，因为它根据 TTL 将 <code>tools/list</code> 视为可缓存的。也许服务器是众多将 <code>listChanged</code> 能力标志设为 true 但从未真正触发通知的实现之一，因为底层集成是后来强行加入的，没人编写相关的事件逻辑。在这三种情况下，客户端的结果都是一样的：它正基于对服务器权限的陈旧视图进行操作。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="为什么-llm-的臆测能命中真实的工具">为什么 LLM 的臆测能命中真实的工具<a href="https://tianpan.co/zh/blog/2026-06-03-mcp-tool-list-grew-mid-session-hallucination#%E4%B8%BA%E4%BB%80%E4%B9%88-llm-%E7%9A%84%E8%87%86%E6%B5%8B%E8%83%BD%E5%91%BD%E4%B8%AD%E7%9C%9F%E5%AE%9E%E7%9A%84%E5%B7%A5%E5%85%B7" class="hash-link" aria-label="为什么 LLM 的臆测能命中真实的工具的直接链接" title="为什么 LLM 的臆测能命中真实的工具的直接链接" translate="no">​</a></h2>
<p>关于 LLM 幻觉出工具名称的幼稚想法是模型发明了一个不存在的字符串。随后模型会被协议错误纠正，智能体使用存在的工具重试，审计追踪保持干净。</p>
<p>危险的情况是模型发明了一个确实存在的字符串。工具名称往往趋同。连接到项目追踪器的空间集成极有可能暴露名为 <code>create_issue</code>、<code>update_issue</code>、<code>list_issues</code> 之类的工具。如果模型已经在足够多的公开 MCP 服务器上进行过训练（事实确实如此），那么当上下文暗示已连接问题追踪器时，它就会猜测其中一个名称。如果服务器在 5 秒前刚刚添加了该集成，而客户端尚未看到新的工具列表，模型的猜测就会命中。</p>
<p>服务器没有理由拒绝。从它的角度来看，工具存在，调用者已通过身份验证，调用格式有效。在 <code>tools/call</code> 请求中没有哪个字段可以断言“我被告知过这个工具的存在”。即使有，服务器也无法验证该声明，因为协议中不包含针对过去 <code>tools/list</code> 响应的签名收据。</p>
<p>在追踪记录中看起来像是智能体在调用一个工具，实际上是智能体在调用一个名称。而这个名称恰好解析为一个工具。审计追踪失去了这种区分。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="并不总是奏效的通知通道">并不总是奏效的通知通道<a href="https://tianpan.co/zh/blog/2026-06-03-mcp-tool-list-grew-mid-session-hallucination#%E5%B9%B6%E4%B8%8D%E6%80%BB%E6%98%AF%E5%A5%8F%E6%95%88%E7%9A%84%E9%80%9A%E7%9F%A5%E9%80%9A%E9%81%93" class="hash-link" aria-label="并不总是奏效的通知通道的直接链接" title="并不总是奏效的通知通道的直接链接" translate="no">​</a></h2>
<p>规范对会话中变更的对策是 <code>notifications/tools/list_changed</code>。在一个运行良好的系统中，这应该能弥合差距：服务器在界面变化时告知客户端；客户端重新获取；智能体的下一回合看到新列表，LLM 的选择建立在真实的基础之上。</p>
<p>在实践中，通知通道是设计中最薄弱的部分。原因如下：</p>
<ul>
<li class=""><strong>传输歧义。</strong> 2025-06-18 规范假设了一个可以推送通知的持久连接（stdio、SSE）。2026-07-28 发布候选版转向了可在普通基础设施上扩展的无状态 HTTP 核心。在无状态模式下，服务器无处推送通知，因此客户端必须轮询或依赖缓存头。许多实际部署混合了传输方式 —— 在有状态服务器前放置无状态网关 —— 导致通知在衔接处丢失。</li>
<li class=""><strong>能力宣告沦为“表演”。</strong> 服务器声明 <code>listChanged: true</code> 只是因为 SDK 模板中有该字段。没有任何机制测试当列表变化时是否真的触发了通知。能力位变成了意愿的说明，而非行为的事实。</li>
<li class=""><strong>客户端调度竞争。</strong> harness 接收到通知时正值回合中途，此时工具调用已经在进行中，或者模型正在生成。幼稚的实现会将重新获取排队到“当前回合结束后”。当前回合通过调用一个工具结束，该工具名称是根据通知前的列表选择的，但却是根据通知后的界面解析的。</li>
</ul>
<p>2026 发布候选版中引入的 <code>ttlMs</code> 和 <code>cacheScope</code> 字段在列表响应中有助于轮询情况，但对调度竞争无济于事。即使是一个具有 1 秒 TTL 的列表，仍然存在 1 秒的窗口期，在此期间客户端的理解滞后于服务器的真实情况。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="权限问题的核心所在">权限问题的核心所在<a href="https://tianpan.co/zh/blog/2026-06-03-mcp-tool-list-grew-mid-session-hallucination#%E6%9D%83%E9%99%90%E9%97%AE%E9%A2%98%E7%9A%84%E6%A0%B8%E5%BF%83%E6%89%80%E5%9C%A8" class="hash-link" aria-label="权限问题的核心所在的直接链接" title="权限问题的核心所在的直接链接" translate="no">​</a></h2>
<p>值得指出的架构错误在于将工具列表视为“清单 (inventory)”而非“授权 (grant)”。清单是“这里存在什么”，而授权则是“我批准你调用什么”。这两个概念并不相同，而协议却将它们混为一谈。</p>
<p>当 LLM 选择工具时，决策应受授权而非清单的限制。授权是用户或操作员在连接服务器时同意的内容，加上他们在发现步骤中获知的内容。清单则是服务器当前碰巧公开的内容。在授权发布后加入清单的工具不属于该授权。从安全审查的角度来看，调用它的模型正在采取未经授权的操作——即使服务器毫无怨言地接受了调用。</p>
<p>该协议没有内置的方法来编码这种区别。<code>tools/call</code> 请求携带的是名称，而不是对特定 <code>tools/list</code> 响应的引用。服务器不知道客户端查看的是哪个版本的列表。客户端也不知道服务器现在提供的是哪个版本的列表。</p>
<p>弥合这一差距需要在两端都进行显式的工作。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="弥合差距的模式">弥合差距的模式<a href="https://tianpan.co/zh/blog/2026-06-03-mcp-tool-list-grew-mid-session-hallucination#%E5%BC%A5%E5%90%88%E5%B7%AE%E8%B7%9D%E7%9A%84%E6%A8%A1%E5%BC%8F" class="hash-link" aria-label="弥合差距的模式的直接链接" title="弥合差距的模式的直接链接" translate="no">​</a></h2>
<p>有几种模式可以解决不同部分的失效问题，大多数生产部署需要不止一种模式。</p>
<ul>
<li class=""><strong>工具调用中的列表版本锁定 (List-version pinning)。</strong> 扩展 <code>tools/call</code> 以携带调用方所依据的 <code>tools/list</code> 响应的版本（或哈希值）。服务器拒绝引用过时或未知版本的调用。这是最彻底的修复方式——它使授权在每次调用中都变得明确——但这需要双方都实现该扩展，而规范尚未强制要求。</li>
<li class=""><strong>针对高变动服务器在每轮对话中重新获取。</strong> 在 harness 配置中将服务器标记为“高变动 (high-mutation)”，并在每个智能体回合开始时重新获取其工具列表。延迟成本是真实存在的（每个服务器每轮额外增加一次往返），但对于像项目追踪器或代码库工具这样底层表面确实会发生变化的服务器来说，这个成本是获得可靠工具列表的代价。</li>
<li class=""><strong>服务端授权执行。</strong> 让服务器按会话跟踪哪些工具已披露给调用方。如果调用在针对该会话的最新 <code>tools/list</code> 之后添加的工具，则返回软失败响应，提示客户端在重试前重新列出。这是授权模型的服务端强制执行；它不需要客户端的配合。</li>
<li class=""><strong>在 harness 中拒绝新工具 (Reject-on-novel)。</strong> 在 harness 将 <code>tools/call</code> 转发给服务器之前，根据缓存列表检查请求的工具名称。如果不存在，则在调用离开 harness 之前拒绝它——即使 LLM 确信该工具存在。模型认为工具存在的信念并不能证明它确实存在，而 harness 拥有作为权威的缓存列表。</li>
<li class=""><strong>带有同步刷新的通知驱动缓存失效。</strong> 当 <code>notifications/tools/list_changed</code> 到达时，不要将重新获取排入队列，而是阻塞下一次外发调用，直到拿到新列表。用少量的延迟损失换取表面的一致性是值得的权衡。</li>
<li class=""><strong>带有披露凭据的单工具可审计性。</strong> 为每次工具调用记录向调用方披露该工具的 <code>tools/list</code> 响应的时间戳。如果不存在此类响应，则按定义该调用属于安全事件。审计轨迹随后可以回答安全团队的问题——“智能体是如何得知这个工具的”——即便答案是“它根本没得知”。</li>
</ul>
<p>这些模式在延迟和一致性之间进行了权衡。每轮重新获取速度较慢但更准确。锁定列表版本更具协作性，但需要双边实现。拒绝新工具是单方面的，但会惩罚模型从部分列表中有用恢复的合法用例。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="工具表面即合约">工具表面即合约<a href="https://tianpan.co/zh/blog/2026-06-03-mcp-tool-list-grew-mid-session-hallucination#%E5%B7%A5%E5%85%B7%E8%A1%A8%E9%9D%A2%E5%8D%B3%E5%90%88%E7%BA%A6" class="hash-link" aria-label="工具表面即合约的直接链接" title="工具表面即合约的直接链接" translate="no">​</a></h2>
<p>值得建立的思想模型是，服务器公开的工具表面不是静态目录，而是不断修订的合约。表面的每一次变动都是对智能体授权行为的变动。将表面视为可以无限期缓存，无异于将一份有效的合约视为博物馆展品。</p>
<p>这并非 MCP 所独有。任何可用操作集可能在会话中途发生变化，且调用层会缓存该集合的系统都面临同样的差距——REST API 网关、具有动态字段暴露的 GraphQL schemas、在单个方法上带有特性标志 (feature flags) 的 RPC 服务。MCP 只是让这种差距变得更糟，因为调用方是一个 LLM，其幻觉非常擅长生成恰好真实的名称。</p>
<p>安全交付 MCP 集成的团队将不再把 <code>tools/list</code> 视为发现步骤，而是将其视为授权步骤，每当底层授权可能发生变化时都需要重复该步骤。在敏感调用之前重新获取列表的 harness 并没有增加延迟——它增加的是合约检查。捕获哪个列表版本授权了每次调用的审计日志并不是额外的文书工作——它是区分“智能体在授权范围内行动”与“智能体猜中了一个真实名称”的唯一方法。</p>
<p>工具列表不是服务器能做什么。它是智能体被允许请求服务器做什么。以此来看待它，差距就会弥合。将其视为库存清单，你交付的智能体其幻觉就能购买真实的东西。</p>]]></content>
        <author>
            <name>Tian Pan</name>
            <uri>https://tianpan.co</uri>
        </author>
        <category label="mcp" term="mcp"/>
        <category label="ai-agents" term="ai-agents"/>
        <category label="security" term="security"/>
        <category label="protocol-design" term="protocol-design"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[那个因冗长输出比更佳答案更能触发点击处理器的 A/B 测试赢家]]></title>
        <id>https://tianpan.co/zh/blog/2026-06-03-the-ab-test-winner-whose-verbose-output-triggered-your-click-handler-more-than-the-better-answer</id>
        <link href="https://tianpan.co/zh/blog/2026-06-03-the-ab-test-winner-whose-verbose-output-triggered-your-click-handler-more-than-the-better-answer"/>
        <updated>2026-06-03T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[为什么基于参与度指标的提示词实验往往会发布更长的变体，以及在满意度下降迫使人们重新审视之前，如何识别那些将回复形式与回复质量脱钩的模式。]]></summary>
        <content type="html"><![CDATA[<p>一项提示词变体（prompt-variant）实验在某款 AI 辅助搜索产品的生产流量上运行。成功指标是点击响应中的任何建议操作。变体 B 交付的响应长度增加了大约 40%，且包含更多列举出的选项。点击率（CTR）高出 11%，且具有三个九（99.9%）的统计显著性。该实验被判定为获胜并上线。</p>
<p><img decoding="async" loading="lazy" src="https://opengraph-image.blockeden.xyz/api/og-tianpan-co?title=%E9%82%A3%E4%B8%AA%E5%9B%A0%E5%86%97%E9%95%BF%E8%BE%93%E5%87%BA%E6%AF%94%E6%9B%B4%E4%BD%B3%E7%AD%94%E6%A1%88%E6%9B%B4%E8%83%BD%E8%A7%A6%E5%8F%91%E7%82%B9%E5%87%BB%E5%A4%84%E7%90%86%E5%99%A8%E7%9A%84%20A%2FB%20%E6%B5%8B%E8%AF%95%E8%B5%A2%E5%AE%B6" alt="" class="img_ev3q"></p>
<p>一个月后，每周客户满意度调查下降了两个点。没人将其与上线联系起来，因为实验已被记录为成功，团队已经转向其他工作。季度复盘最终将满意度下降追溯到提示词的更改，诊断结果令人难以接受：变体 B 胜出并不是因为它给了用户更好的答案，而是因为更长的回答包含了更多的点击表面（clickable surfaces）。点击处理器在每次展示中触发得更频繁，是因为有更多可点击的内容，而不是因为你阅读的内容更值得采取行动。</p>
<p>错误不在统计数据上。p 值是真实的，提升是真实的，样本量也是诚实的。错误在于成功指标衡量的是响应的“形状”（shape），而响应的形状是提示词变体可以在不改变潜在质量的情况下直接改变的东西。这场实验是在错误的维度上进行的一场公平竞争。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="形状如何在无人作弊的情况下获胜">形状如何在无人作弊的情况下获胜<a href="https://tianpan.co/zh/blog/2026-06-03-the-ab-test-winner-whose-verbose-output-triggered-your-click-handler-more-than-the-better-answer#%E5%BD%A2%E7%8A%B6%E5%A6%82%E4%BD%95%E5%9C%A8%E6%97%A0%E4%BA%BA%E4%BD%9C%E5%BC%8A%E7%9A%84%E6%83%85%E5%86%B5%E4%B8%8B%E8%8E%B7%E8%83%9C" class="hash-link" aria-label="形状如何在无人作弊的情况下获胜的直接链接" title="形状如何在无人作弊的情况下获胜的直接链接" translate="no">​</a></h2>
<p>AI 产品的参与度指标在输出的表面积与任何单一参与事件触发的概率之间存在一种隐藏的耦合。点击率、操作调用率、建议后续动作采纳率 —— 每一项都是针对响应本身产生的事件进行计算的。一个包含三个建议操作的响应有三次触发指标的机会，而包含七个的则有七次。你并没有变得更感兴趣；而是响应变得更“好点”了。</p>
<p>这并非 LLM 所特有。产品团队多年来一直在衡量长内容的参与度，并且知道分页、无限滚动和推荐轮播都会通过增加参与项的库存来夸大参与计数。新鲜之处在于 LLM 改变输出形状的成本是多么低廉。仅需一行提示词修改，就能将答案从三个要点变成十个。无需设计评审，无需开发工单，也无需权衡用户体验。 “响应呈现多少表面积”的变动空间对任何提示词实验都是敞开的，任何与单次展示参与度相关的指标都会默默地奖励这种扩张，直到其他地方出现问题。</p>
<p>这种现象也延伸到了点击之外。当响应内容更长、阅读时间更久时，页面停留时间会上升。当有更多独立的块可以复制时，剪贴板复制率会上升。点赞按钮的点击量可能会增加，因为冗长的答案让你感觉更“完整”，即便它们在简短答案也会犯错的地方同样犯错，只是篇幅更长。你所衡量的任何处于“响应产生更多文本”下游的指标，都会奖励产生更多文本的行为。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="为什么长度偏见有着似曾相识的渊源">为什么长度偏见有着似曾相识的渊源<a href="https://tianpan.co/zh/blog/2026-06-03-the-ab-test-winner-whose-verbose-output-triggered-your-click-handler-more-than-the-better-answer#%E4%B8%BA%E4%BB%80%E4%B9%88%E9%95%BF%E5%BA%A6%E5%81%8F%E8%A7%81%E6%9C%89%E7%9D%80%E4%BC%BC%E6%9B%BE%E7%9B%B8%E8%AF%86%E7%9A%84%E6%B8%8A%E6%BA%90" class="hash-link" aria-label="为什么长度偏见有着似曾相识的渊源的直接链接" title="为什么长度偏见有着似曾相识的渊源的直接链接" translate="no">​</a></h2>
<p>如果“冗长即奖励”的失败模式听起来很耳熟，那确实应该如此。LLM 评估社区多年来一直在与基于评委（judge-based）对比中的长度偏见（length bias）作斗争。像 GPT-4 这样的模型，在被要求从两个候选响应中做出选择时，即使有明确的准则要求看重简洁性，也会系统性地更倾向于较长的响应。这种偏见是如此顽固，以至于从业者现在通常会发布长度归一化后的胜率，文献中也已明确命名了这一现象。</p>
<p>同样的动态正出现在更高一层的产品分析中。生产环境中的评委不是 LLM，而是点击处理器。其机制不同 —— 你点击更多是因为按钮更多，而不是因为你在认知上更喜欢长文本 —— 但失败模式在结构上是完全相同的。处于响应形状下游的指标，必然会向能够最大化其触发机会的响应形状倾斜。交付更长变体的团队所发现的，正是自聊天机器人时代开启以来一直扭曲 LLM-as-judge 基准测试的同一种长度偏见的产品化版本。</p>
<p>这种联系之所以重要，是因为它告诉你修复方法并非针对单次实验的一次性修正。修复必须成为实验系统本身的一种属性。任何针对输出形状可变的产品，利用参与度指标来运行提示词或模型变体的团队都面临风险。更长的变体往往会胜出。如果团队不将这种动态命名为每次实验的协变量（covariate），就会不断上线冗长的内容，直到数月后下游的满意度信号迫使他们进行清算。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="指标完全是在各司其职">指标完全是在各司其职<a href="https://tianpan.co/zh/blog/2026-06-03-the-ab-test-winner-whose-verbose-output-triggered-your-click-handler-more-than-the-better-answer#%E6%8C%87%E6%A0%87%E5%AE%8C%E5%85%A8%E6%98%AF%E5%9C%A8%E5%90%84%E5%8F%B8%E5%85%B6%E8%81%8C" class="hash-link" aria-label="指标完全是在各司其职的直接链接" title="指标完全是在各司其职的直接链接" translate="no">​</a></h2>
<!-- -->
<div class="loading_VaNF">加载中…</div>]]></content>
        <author>
            <name>Tian Pan</name>
            <uri>https://tianpan.co</uri>
        </author>
        <category label="insider" term="insider"/>
        <category label="ab-testing" term="ab-testing"/>
        <category label="llm-evaluation" term="llm-evaluation"/>
        <category label="product-metrics" term="product-metrics"/>
        <category label="experimentation" term="experimentation"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[那个因无人负责而逃过租户删除清理的智能体记忆库]]></title>
        <id>https://tianpan.co/zh/blog/2026-06-03-the-agent-memory-store-that-survived-your-tenant-deletion-because-nobody-owned-it</id>
        <link href="https://tianpan.co/zh/blog/2026-06-03-the-agent-memory-store-that-survived-your-tenant-deletion-because-nobody-owned-it"/>
        <updated>2026-06-03T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[当所遍历的资产列表已过时，即使删除流程正确也无济于事。为什么每当发布一个无人申报的新持久化存储时，你的租户删除保证就在悄然失效。]]></summary>
        <content type="html"><![CDATA[<p>合规计划是对审计员签字通过当天你公司所拥有系统的描述。你公司今天的系统是另一套完全不同的组合，而这两者之间的差距，就是从那时到现在期间每一个上线了新持久化存储的发布版本的表面积。你向客户承诺的删除保证是针对第一套系统的保证，而最终对此进行询问的监管机构，问的将是第二套。</p>
<p><img decoding="async" loading="lazy" src="https://opengraph-image.blockeden.xyz/api/og-tianpan-co?title=%E9%82%A3%E4%B8%AA%E5%9B%A0%E6%97%A0%E4%BA%BA%E8%B4%9F%E8%B4%A3%E8%80%8C%E9%80%83%E8%BF%87%E7%A7%9F%E6%88%B7%E5%88%A0%E9%99%A4%E6%B8%85%E7%90%86%E7%9A%84%E6%99%BA%E8%83%BD%E4%BD%93%E8%AE%B0%E5%BF%86%E5%BA%93" alt="" class="img_ev3q"></p>
<p>这种失败模式并非删除代码中的 Bug。删除代码本身是正确的。Saga 流程会扇出到数据清单中命名的每一个存储系统，调用每个系统的删除端点，收集每个系统的回执，并在每个回执都返回已签署状态时报告成功。Saga 正在准确执行其被构建时所承担的任务。问题在于，Saga 迭代的是一份 18 个月前的存储系统列表，而智能体平台团队在 6 个月前上线了一个长期记忆功能，却没有任何人将其添加到该列表中。</p>
<p>这就是摧毁删除保证的鸿沟：“哪些系统存储了租户数据”的权威记录系统与“存在哪些系统”的权威记录系统是不同的产物，由不同的团队维护，且遵循不同的更新节奏。当两者达成一致时，删除 Saga 是完整的。当两者不一致时，删除 Saga 仅针对 <em>清单所描述的世界</em> 是完整的，而非针对现实中存在的那个世界。在监管机构询问之前，这种不一致性是不可见的。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="清单是快照而非实时索引">清单是快照，而非实时索引<a href="https://tianpan.co/zh/blog/2026-06-03-the-agent-memory-store-that-survived-your-tenant-deletion-because-nobody-owned-it#%E6%B8%85%E5%8D%95%E6%98%AF%E5%BF%AB%E7%85%A7%E8%80%8C%E9%9D%9E%E5%AE%9E%E6%97%B6%E7%B4%A2%E5%BC%95" class="hash-link" aria-label="清单是快照，而非实时索引的直接链接" title="清单是快照，而非实时索引的直接链接" translate="no">​</a></h2>
<p>大多数数据清单都是构建一次、审计一次，然后便被期望通过“社会契约”来保持准确性。合规计划始于一场研讨会，会上每个团队都会梳理其存储系统，由分析师将其录入电子表格或 GRC 工具中。电子表格会根据 Schema 注册表进行审查，审计员对覆盖范围签字确认，清单随即成为“该产品使用了哪些存储系统”的权威答案。更新过程是一个手动工单：当你上线一个新的持久化存储时，你应该向合规团队的 Backlog 提交一个工单，而他们应该在清单中添加一个条目。</p>
<p>更新过程的失败方式与所有跨团队手动流程的失败方式如出一辙。负责上线存储系统的团队有冲刺截止日期。合规团队有任务积压。工单被提交，被列为“日常维护”优先级，并排在本季度审计准备工作的后面。几个月过去了。下一季度的合规审查无法发现这个缺口，因为审查是将清单与上一次审计进行核对，而不是与生产环境核对。清单在内部是自洽的，但在外部却是错误的。</p>
<p>让问题更加复杂的是，在 AI 密集型产品中，最常见的新存储系统恰恰是那些最不可能被申报的系统。带有 tenant_id 列的关系型数据库会立即被列入清单，因为 Schema 审查会强制执行这一点。而一个通过合成的智能体会话 ID（agent-session-id）作为键，并通过另一个单独的表关联到 tenant_id 的向量存储，在审查员看来，更像是一个内部缓存。一个存储智能体状态的 KV 存储，工程师在设计文档中将其描述为“临时工作内存”，实际上却因为逐出策略为了优化检索质量而被保留了六个月。这些都不会被申报，因为它们看起来都不像是个人数据的权威记录系统，而且负责上线的工程师也不具备识别它们属于此类系统的合规词汇量。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="saga-流程针对给定的清单成功执行">Saga 流程针对给定的清单成功执行<a href="https://tianpan.co/zh/blog/2026-06-03-the-agent-memory-store-that-survived-your-tenant-deletion-because-nobody-owned-it#saga-%E6%B5%81%E7%A8%8B%E9%92%88%E5%AF%B9%E7%BB%99%E5%AE%9A%E7%9A%84%E6%B8%85%E5%8D%95%E6%88%90%E5%8A%9F%E6%89%A7%E8%A1%8C" class="hash-link" aria-label="Saga 流程针对给定的清单成功执行的直接链接" title="Saga 流程针对给定的清单成功执行的直接链接" translate="no">​</a></h2>
<p>删除 Saga 是架构正确性的典范。它读取配置（清单），迭代其条目，使用租户 ID 调用每个系统的删除端点，等待确认，并将回执汇总到最终报告中。该 Saga 是可测试的、可重试的且可观测的。每个步骤都会发出指标。操作手册非常清晰。当 Saga 报告成功时，On-call 工程师可以安心回床睡觉。</p>
<p>Saga 的正确性恰恰是问题所在。由于 Saga 针对其输入是正确的，因此删除操作悄然失败的地方位于该 Saga 负责的任何代码的上游。这里没有异常，没有失败的回执，也没有指标异常。Saga 向审计日志写入了一条干净的记录，显示“租户 X 在所有系统中已删除”。这份审计日志正是法务团队向监管机构出示的内容。监管机构读取日志，看到一条清晰的轨迹，调查便继续进行。多年后，当另一场审计（通常是来自不相关客户的 DSAR，由于偶然在智能体响应中出现了相关租户的片段）暴露了这个缺口时，法务团队的第一个问题是“删除操作运行了吗？”审计日志说是。向量存储说“差不多吧”。而这两个答案之间的对账，正是无人认领的灰色地带。</p>
<p>这就是再次重申的“合约 vs 实现”差距：合约规定“我们根据请求删除”，Saga 实现了“我们调用清单中每个系统的删除操作”，而合约与实现之间的差距正是清单与现实之间的差异。大多数团队并没有一个专门的岗位来消除这一差距。他们会有季度审计来重新认证清单，但审计查看的是清单中 <em>已有的</em> 内容，而不是清单中 <em>本应有却缺失</em> 的内容。审计在结构上无法发现它所不知道的存储系统。</p>
<!-- -->
<div class="loading_VaNF">加载中…</div>]]></content>
        <author>
            <name>Tian Pan</name>
            <uri>https://tianpan.co</uri>
        </author>
        <category label="insider" term="insider"/>
        <category label="compliance" term="compliance"/>
        <category label="agent-memory" term="agent-memory"/>
        <category label="gdpr" term="gdpr"/>
        <category label="data-inventory" term="data-inventory"/>
        <category label="platform-engineering" term="platform-engineering"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[用户学会利用 Agent 超时机制套取退款]]></title>
        <id>https://tianpan.co/zh/blog/2026-06-03-the-agent-timeout-your-users-learned-to-game-for-refunds</id>
        <link href="https://tianpan.co/zh/blog/2026-06-03-the-agent-timeout-your-users-learned-to-game-for-refunds"/>
        <updated>2026-06-03T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[某 Agent 平台慷慨的“超时退款”政策催生了一个特定的用户群体，他们系统性地利用这一边界规则，使超时率翻倍，并将这种行为伪装成产品质量退化。]]></summary>
        <content type="html"><![CDATA[<p>某平台发布了一个针对长耗时智能体（agent）任务的 30 分钟实际时间上限，并配套了一项退款政策：任何达到超时上限且未产生交付成果的任务，其消耗的 token 费用将予以退还。其初衷是保护性的：挂起的智能体不应向客户收费。六个月后，超时率翻了一番，工程团队深陷“智能体可靠性”调查，而支持队列中挤满了抱怨智能体“不断超时”的用户——截图显示，用户的浏览器标签页在 29 分多钟时就被关闭了。</p>
<p><img decoding="async" loading="lazy" src="https://opengraph-image.blockeden.xyz/api/og-tianpan-co?title=%E7%94%A8%E6%88%B7%E5%AD%A6%E4%BC%9A%E5%88%A9%E7%94%A8%20Agent%20%E8%B6%85%E6%97%B6%E6%9C%BA%E5%88%B6%E5%A5%97%E5%8F%96%E9%80%80%E6%AC%BE" alt="" class="img_ev3q"></p>
<p>在财务模型从未命名的行为群体中，单位经济效益已悄然倒挂。退款人群并非质量不佳的人群。这是一种策略。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="你提供赔付的边界就是被瞄准的目标">你提供赔付的边界就是被瞄准的目标<a href="https://tianpan.co/zh/blog/2026-06-03-the-agent-timeout-your-users-learned-to-game-for-refunds#%E4%BD%A0%E6%8F%90%E4%BE%9B%E8%B5%94%E4%BB%98%E7%9A%84%E8%BE%B9%E7%95%8C%E5%B0%B1%E6%98%AF%E8%A2%AB%E7%9E%84%E5%87%86%E7%9A%84%E7%9B%AE%E6%A0%87" class="hash-link" aria-label="你提供赔付的边界就是被瞄准的目标的直接链接" title="你提供赔付的边界就是被瞄准的目标的直接链接" translate="no">​</a></h2>
<p>平台针对任何运营阈值提供的赔付，都会成为用户可以学习并着陆的坐标。这在云计算或 SaaS 领域并不新鲜——SLA 赔偿、退款窗口和免费层级上限已被“薅羊毛”数十年——但智能体平台面临着该问题的一个独特且尖锐的版本，因为单个任务的成本高到足以值得针对其进行单独优化。</p>
<p>从用户的角度来看，计算过程很简单。一个完成的复杂多小时任务将按智能体消耗的全部 token 计费。而同一个任务在 28 分钟处被中断，触及退款边界，退回大部分费用，且由此产生的上下文状态（in-context state）可以在后续轮次中以较低成本恢复，而无需从零开始。一个每周运行十个此类工作流并精于计算的用户，会在每一次高成本运行中刻意导向该边界。</p>
<p>高级用户最先发现裂缝。他们是资金风险最高、最有动力仔细阅读政策、且在操作上足够成熟能围绕限制条件重构工作流的群体。他们也是平台最不想失去的群体，这也是为什么退款政策最初设定得很慷慨。</p>
<p>平台的响应曲线完全搞错了方向。超时率上升，于是可靠性团队调查智能体。智能体表现正常。退款账单上升，财务将其视为平台故障成本升高。值班人员没看到任何事故。每个仪表盘报告的问题其病因都与实际发生的不同，因为这些仪表盘在设计时，是将用户视为智能体行为的被动接受者，而非定价博弈的参与者。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="为什么这种失败模式是智能体平台特有的">为什么这种失败模式是智能体平台特有的<a href="https://tianpan.co/zh/blog/2026-06-03-the-agent-timeout-your-users-learned-to-game-for-refunds#%E4%B8%BA%E4%BB%80%E4%B9%88%E8%BF%99%E7%A7%8D%E5%A4%B1%E8%B4%A5%E6%A8%A1%E5%BC%8F%E6%98%AF%E6%99%BA%E8%83%BD%E4%BD%93%E5%B9%B3%E5%8F%B0%E7%89%B9%E6%9C%89%E7%9A%84" class="hash-link" aria-label="为什么这种失败模式是智能体平台特有的的直接链接" title="为什么这种失败模式是智能体平台特有的的直接链接" translate="no">​</a></h2>
<p>传统 SaaS 只给用户提供几个粗粒度的杠杆——取消、降级、争议账单。智能体平台则暴露了更细粒度的行为控制，直接映射到单位成本上。用户可以决定何时终止任务、如何组织下一条消息、是否生成子智能体、何时提交工具调用。每一个都是账单上的旋钮。</p>
<p>这种超时退款博弈在结构上类似于过去两年记录在案的针对云 API 的成本攻击类：随使用量扩展的定价表面，产生了一种推动使用量向平台非预期方向发展的激励。智能体的不同之处在于，优化者是客户而非攻击者，且其行为并非恶意——用户只是在阅读并执行书面政策。</p>
<p>第二个复合因素：智能体运行时间足够长，以至于用户有时间在运行中途决定是否让其完成。一个耗时两秒的 API 调用没有有用的“在此中止以获取退款”的空间。一个 28 分钟的智能体任务则有。正是由于长耗时智能体具有价值的特性——持久、多步骤的工作——也给用户提供了一个针对计费边界进行优化的窗口。</p>
<p>第三个因素，也是将其从孤立的小技巧转变为行为群体的原因：来自部分完成的智能体运行的上下文状态是有价值的。如果用户在退款触发后可以廉价地恢复运行，他们实际上已经将“智能体完成了大部分工作”转变为“智能体免费完成了大部分工作”。平台的检查点与恢复（checkpoint-and-resume）能力——作为可靠性功能出售——正兼职作为计费漏洞。可靠性和退款套利共享同一条代码路径，而平台并未意识到它在同时为这两者提供资金。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="诊断结果看起来像是质量倒退">诊断结果看起来像是质量倒退<a href="https://tianpan.co/zh/blog/2026-06-03-the-agent-timeout-your-users-learned-to-game-for-refunds#%E8%AF%8A%E6%96%AD%E7%BB%93%E6%9E%9C%E7%9C%8B%E8%B5%B7%E6%9D%A5%E5%83%8F%E6%98%AF%E8%B4%A8%E9%87%8F%E5%80%92%E9%80%80" class="hash-link" aria-label="诊断结果看起来像是质量倒退的直接链接" title="诊断结果看起来像是质量倒退的直接链接" translate="no">​</a></h2>
<p>这是该失败模式中代价最高昂的部分，因为它将响应路由到了错误的团队。</p>
<p>当超时率翻倍时，工程内部的自然解读是“智能体变得越来越糟”。可靠性调查随之启动。评估套件重新运行。模型版本进行二分查找。工具延迟被审计。结果一无所获，因为没有任何倒退——智能体表现得和以前完全一样，只是其承载的工作负载发生了偏移。</p>
<!-- -->
<div class="loading_VaNF">加载中…</div>]]></content>
        <author>
            <name>Tian Pan</name>
            <uri>https://tianpan.co</uri>
        </author>
        <category label="insider" term="insider"/>
        <category label="agent-platforms" term="agent-platforms"/>
        <category label="pricing" term="pricing"/>
        <category label="billing" term="billing"/>
        <category label="unit-economics" term="unit-economics"/>
        <category label="reliability" term="reliability"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[代理墙钟预算：一场与工具超时机制的赛跑]]></title>
        <id>https://tianpan.co/zh/blog/2026-06-03-the-agent-wall-clock-budget-that-raced-your-tools-own-timeout</id>
        <link href="https://tianpan.co/zh/blog/2026-06-03-the-agent-wall-clock-budget-that-raced-your-tools-own-timeout"/>
        <updated>2026-06-03T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[在代理技术栈内部，代理的时钟与工具的时钟几乎从未共享同一个零时刻 (t-zero)。当它们的预算发生偏差时，一个耗时 8 秒的工具调用可能会撞上 7.9 秒的截止期限，导致框架针对一个它从未见过的“成功”结果进行重新规划。]]></summary>
        <content type="html"><![CDATA[<p>有一种 Agent 漏洞，当你孤立地观察任何单个组件时，它都不会出现。模型没问题，工具没问题，重试策略也没问题。纸面上的超时值甚至可以说很慷慨。然而，一个通常在 8 秒内完成的工具，却总是在一个已经在 7.9 秒时将其宣告为失败的 Agent 面前折戟。Agent 围绕一个从未发生过的“错误”重新规划，并启动了第二次调用，而第一次调用的结果即将与其发生碰撞。</p>
<p><img decoding="async" loading="lazy" src="https://opengraph-image.blockeden.xyz/api/og-tianpan-co?title=%E4%BB%A3%E7%90%86%E5%A2%99%E9%92%9F%E9%A2%84%E7%AE%97%EF%BC%9A%E4%B8%80%E5%9C%BA%E4%B8%8E%E5%B7%A5%E5%85%B7%E8%B6%85%E6%97%B6%E6%9C%BA%E5%88%B6%E7%9A%84%E8%B5%9B%E8%B7%91" alt="" class="img_ev3q"></p>
<p>漏洞不在任何一个框框里。它存在于两个没人同意应该同步的时钟之间的缝隙中。</p>
<p>这是 Agent 工程领域中相当于分布式系统经典问题——协作节点之间的时钟漂移 (clock drift)——只不过这两个节点位于同一个进程中，且漂移不是以 NTP 偏差的毫秒为单位衡量的。它是以每一方决定称为“t-zero”的事件来衡量的。Agent 的预算往往从 LLM 发出第一个 token 开始计时。工具的预算往往从工具进程实际收到调用的那一刻开始计时。在这两个时刻之间，隔着整个 prefill 阶段、整个流式传输管道、整个工具路由器 (tool-router) 跳转，以及工具工作线程 (tool worker) 前的任何排队。这两个秒表之间没有任何时间是共享的。它们都认为自己在为同一件事计时。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="两个没人同意应该同步的时钟">两个没人同意应该同步的时钟<a href="https://tianpan.co/zh/blog/2026-06-03-the-agent-wall-clock-budget-that-raced-your-tools-own-timeout#%E4%B8%A4%E4%B8%AA%E6%B2%A1%E4%BA%BA%E5%90%8C%E6%84%8F%E5%BA%94%E8%AF%A5%E5%90%8C%E6%AD%A5%E7%9A%84%E6%97%B6%E9%92%9F" class="hash-link" aria-label="两个没人同意应该同步的时钟的直接链接" title="两个没人同意应该同步的时钟的直接链接" translate="no">​</a></h2>
<p>对 Agent 步骤的传统看法就像时间轴上的一个条形图：提示词输入，模型思考，工具运行，结果返回。对超时的传统看法是，你在该条形图的某个地方画一条垂直线，并称之为截止日期。</p>
<p>现实情况是至少有四个时钟在并发运行，且几乎从未同步：</p>
<ul>
<li class="">Agent 框架 (harness) 时钟，通常在请求发送给模型时开始计算预算。</li>
<li class="">模型的有效时钟，框架通常只有在第一个流式 token (TTFT) 出现时才能感知到。在 2026 年，对于对话式生成，首字时间被报告在数百毫秒到几秒不等，对于长上下文可能更长。这整个间隔可能算作，也可能不算作你的“Agent 预算”，具体取决于你使用的库。</li>
<li class="">工具客户端时钟，从框架发出工具调用开始，到工具返回结束。大多数框架将其公开为每个工具的超时。</li>
<li class="">工具服务端时钟，仅在工具进程实际收到请求时开始。它前面的任何东西——队列、MCP 路由器、具有自身空闲超时的反向代理——都会增加工具进程看不见且无法计入自身的延迟。</li>
</ul>
<p>在表现良好的 RPC 栈中，这些时钟通过截止日期传播 (deadline propagation) 来协调。调用方计算一个绝对截止日期，将其作为墙钟瞬间 (而非持续时间) 附加到请求中，每个下游跳转都会继承它。经典的 gRPC 公式对此非常明确：截止日期是以绝对时间戳表示的“从现在起 5 秒内”，并且上下文会通过每个子调用进行传播，以便整个子树在同一时刻终止。像 userver 这样的框架也描述了同样的想法——链接截止日期，这样缓慢的上游就不会消耗掉下游仍期望拥有的预算。</p>
<p>在实践中，Agent 栈并不这样做。Agent 的预算是在框架进程中计算的持续时间。工具的预算是在工具进程中计算的另一个独立的持续时间。没有共享的截止日期。没有传播。</p>
<p>因此，当模型的第一个 token 终于到达时，框架时钟已经过去了 1100 毫秒。当工具调用经过 MCP 服务器路由时，又消失了 300 毫秒。当工具工作线程出队请求并开始其自身的 8 秒预算时，框架已经在针对一个 1.4 秒前就开始的 8 秒预算进行计时。从框架的角度看，工具还有 6.6 秒。从工具的角度看，它有 8 秒。数字看起来相等，实则不然。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="为什么这看起来像是一个-agent-拒绝使用的成功工具调用">为什么这看起来像是一个 Agent 拒绝使用的成功工具调用<a href="https://tianpan.co/zh/blog/2026-06-03-the-agent-wall-clock-budget-that-raced-your-tools-own-timeout#%E4%B8%BA%E4%BB%80%E4%B9%88%E8%BF%99%E7%9C%8B%E8%B5%B7%E6%9D%A5%E5%83%8F%E6%98%AF%E4%B8%80%E4%B8%AA-agent-%E6%8B%92%E7%BB%9D%E4%BD%BF%E7%94%A8%E7%9A%84%E6%88%90%E5%8A%9F%E5%B7%A5%E5%85%B7%E8%B0%83%E7%94%A8" class="hash-link" aria-label="为什么这看起来像是一个 Agent 拒绝使用的成功工具调用的直接链接" title="为什么这看起来像是一个 Agent 拒绝使用的成功工具调用的直接链接" translate="no">​</a></h2>
<p>病理性的情况并不是 Agent 在工具启动前就放弃了。那种情况非常明显——你会看到一个被取消的调用和一个愤怒的日志。病理性的情况是 Agent 在 7.9 秒时放弃，工具在 8.0 秒时完成，且双方都在各自的追踪 (trace) 中写入了结构化的成功日志。</p>
<p>Agent 的追踪显示：启动调用，等待，超时在 7.9 秒过期，重新规划。从 Agent 的角度来看，工具失败了。它进入了一个恢复分支——选择一个不同的工具，向用户询问澄清问题，或者最糟糕的是，重试相同的调用。</p>
<p>工具'的追踪显示：收到调用，执行，在 8.0 秒返回并带有干净的 200 状态码。从工具的角度来看，一切正常。它甚至还因为这项工作向用户收了费。</p>
<p>这种竞争在下游表现为三种不同的症状，它们看起来都像是不同的漏洞：</p>
<ol>
<li class="">一个没有任何东西在等待的“成功”工具结果。Agent 的协程已被取消。结果落入了一个关闭的通道或孤儿 future 中。一些框架将其记录为“tool_use without matching tool_result”——如果双方对发生的事情达成一致，这种形式本应是不可能的。</li>
<li class="">一个自信地报告“工具失败了，我将尝试不同的方法”的模型，而一个完全正确的答案正坐在工具的响应队列中。</li>
<li class="">重复调用，因为 Agent 的重新规划选择了具有相同参数的相同工具——而现在工具工作线程正在做两次相同的工作，第二次调用正与第一个过时的结果赛跑，进入一个不知道该信任哪一个的状态机。</li>
</ol>
<!-- -->
<div class="loading_VaNF">加载中…</div>]]></content>
        <author>
            <name>Tian Pan</name>
            <uri>https://tianpan.co</uri>
        </author>
        <category label="insider" term="insider"/>
        <category label="agents" term="agents"/>
        <category label="llm-infrastructure" term="llm-infrastructure"/>
        <category label="distributed-systems" term="distributed-systems"/>
        <category label="observability" term="observability"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[用户关闭对话后才完成的异步工具调用]]></title>
        <id>https://tianpan.co/zh/blog/2026-06-03-the-async-tool-call-that-resolved-after-the-user-closed-the-conversation</id>
        <link href="https://tianpan.co/zh/blog/2026-06-03-the-async-tool-call-that-resolved-after-the-user-closed-the-conversation"/>
        <updated>2026-06-03T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[长时间运行的工具调用往往比触发它们的聊天会话存续时间更长。当你关闭标签页时，结果仍然会返回 —— 但面对的却是一个已不存在的对话、错误的会话，或者根本无处投递。]]></summary>
        <content type="html"><![CDATA[<p>智能体（agent）会话模型出现故障的最明显标志，就是当工具结果无处可去时。智能体发起了一个耗时较长的调用——例如渲染、资源配置任务或多步查询。用户盯着加载图标看了几秒钟，觉得终究还是不需要，于是关闭标签页并离开了。40 秒后工具运行结束。它的回调（callback）携带着一个不再指向任何内容的 <code>conversation_id</code> 命中你的网关。网关面临两个同样糟糕的选择：默默丢弃该结果，或者将其缝合到接管该 ID 的下一个会话中。</p>
<p><img decoding="async" loading="lazy" src="https://opengraph-image.blockeden.xyz/api/og-tianpan-co?title=%E7%94%A8%E6%88%B7%E5%85%B3%E9%97%AD%E5%AF%B9%E8%AF%9D%E5%90%8E%E6%89%8D%E5%AE%8C%E6%88%90%E7%9A%84%E5%BC%82%E6%AD%A5%E5%B7%A5%E5%85%B7%E8%B0%83%E7%94%A8" alt="" class="img_ev3q"></p>
<p>大多数团队发现这种失败模式的方式都如出一辙：一张服务工单，用户在里面反馈看到了一个他们没问过的问题的答案，且挂载在一个他们并未开启的对话中。或者是下游系统对同一笔费用进行了两次扣款，因为网关“热心地”针对下一个活动会话重试了交付。或者——最常见的情况——表面上什么也看不出来，只是完成率指标（completion metrics）在缓慢下滑，而没人能将其与任何具体原因联系起来，因为这些失败不会触发警报；它们只会触发“空无”。</p>
<p>这与“发后即忘”（fire-and-forget）的失败模式不同，在那种情况下，规划器（planner）将任务 ID 视为最终答案并在不进行轮询的情况下继续运行。那个问题存在于单个智能体循环内部。而本文讨论的问题存在于智能体循环与你其余的基础设施之间：工具<strong>注定会</strong>完成，结果<strong>注定会</strong>送达，而你的会话边界在那之前就已经崩塌了。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="你设计的会话是同步的但工具不是">你设计的会话是同步的；但工具不是<a href="https://tianpan.co/zh/blog/2026-06-03-the-async-tool-call-that-resolved-after-the-user-closed-the-conversation#%E4%BD%A0%E8%AE%BE%E8%AE%A1%E7%9A%84%E4%BC%9A%E8%AF%9D%E6%98%AF%E5%90%8C%E6%AD%A5%E7%9A%84%E4%BD%86%E5%B7%A5%E5%85%B7%E4%B8%8D%E6%98%AF" class="hash-link" aria-label="你设计的会话是同步的；但工具不是的直接链接" title="你设计的会话是同步的；但工具不是的直接链接" translate="no">​</a></h2>
<p>大多数聊天 UI 都是围绕同步的“请求-响应”模式构建的。用户发送消息，模型回答，轮次结束。对话状态存在于内存或短效缓存中；长效状态则在对话结束或一段简短的空闲期后迁移到数据库。整个流水线都假设从用户输入到最终输出的时间是有界的，并且用户在该时间范围内始终保持连接到会话。</p>
<p>工具在无人察觉的情况下打破了这个假设，因为第一波工具——搜索、计算器、词典查询、简单的 API 读取——速度极快，足以适应隐含的“用户还在”的时间预算。接着第二波工具到来了：渲染、转录、资源配置、代码执行、智能体间分发，以及任何调用外部系统且尾延迟以分钟而非秒计的操作。流水线并没有为了匹配这些需求而改变形态。同步会话仍然维持着开启的轮次，规划器仍然期望结果能内联（inline）返回，而唯一能让模型保持“诚实”的约束就是用户一直保持连接。</p>
<p>因此，当用户断开连接时——关闭标签页、导航离开、关闭应用、遇到网络波动、手机锁屏——智能体循环会一直处于挂起状态，等待一个它已无法路由的结果。有些客户端会在服务器端保留该轮次直至 TTL 到期，然后让它默默消亡。另一些则会在断开连接时中止智能体运行，让工具执行在被派发的队列中沦为“孤儿”。无论哪种方式：工作仍在继续，而会话却已不复存在。</p>
<p>这就是差距所在。一个实际持续时间超过会话寿命的工具，<strong>注定会</strong>将其结果落在请求它的会话之外。纠结这种情况何时发生是选错了问题，为这种情况发生时该怎么办进行设计，才是唯一的问题。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="当结果延迟送达时会发生的场景三选一">当结果延迟送达时会发生的场景（三选一）<a href="https://tianpan.co/zh/blog/2026-06-03-the-async-tool-call-that-resolved-after-the-user-closed-the-conversation#%E5%BD%93%E7%BB%93%E6%9E%9C%E5%BB%B6%E8%BF%9F%E9%80%81%E8%BE%BE%E6%97%B6%E4%BC%9A%E5%8F%91%E7%94%9F%E7%9A%84%E5%9C%BA%E6%99%AF%E4%B8%89%E9%80%89%E4%B8%80" class="hash-link" aria-label="当结果延迟送达时会发生的场景（三选一）的直接链接" title="当结果延迟送达时会发生的场景（三选一）的直接链接" translate="no">​</a></h2>
<p>当携带过时 <code>conversation_id</code> 的工具回调到达时，你的路由层会采取以下三种行动之一，而你应该在某些不可抗力逼你做出选择之前，先弄清楚你的系统采取的是哪一种。</p>
<p><strong>丢弃结果。</strong> 网关查找对话，发现已过期，记录一条警告，并向工具服务返回 200 响应以使其不再重试。工具运行了。副作用（side effects）已经产生——扣费成功、邮件已发、行已删除、文档已创建。但没有任何机制告知用户，也没有任何机制告知下一个会话。工作已经在现实世界中完成，但模型对此毫无记忆。下一次用户开启对话询问“那件事办成了吗？”时，智能体必须从现实世界的状态中推导答案，而不是从其自身的历史记录中推导。大多数智能体并非为此构建，它们会毫不犹豫地给出一个或肯定或否定的答案。</p>
<p><strong>路由到下一个会话。</strong> 网关查找对话，发现已过期，然后“热心地”将结果嫁接到该用户接下来开启的任何对话中。下一个会话继承了一个在其历史记录中没有匹配工具调用的工具响应。面对一个悬空的工具结果消息，模型要么忽略它（最好情况），要么幻觉出一个合理的工具调用（一般情况），或者将延迟的结果视为新鲜的用户消息并采取行动（最坏情况——继承副作用，即下一个对话的智能体会根据上一个对话留下的输出来执行额外的工作）。</p>
<p><strong>路由到完全不同的用户。</strong> 这是会让值班人员（on-call）从睡梦中惊醒的情况。<code>conversation_id</code> 被重复使用了，或者用户身份与对话挂钩而非与认证令牌挂钩，或者负载均衡器的键碰撞导致两个会话混淆，又或者是 GC 运行后，一个新生成的 ID 恰好与过期的 ID 碰撞。工具结果出现在了别人的聊天框里。发生一次是侥幸逃过一劫，发生两次就是事故复盘。</p>
<p>第一种失败模式是隐形的，直到你将完成率与断开连接率进行关联分析。第二种也是隐形的，直到用户发现答案与他们的问题不匹配。而第三种，则会自动记录在事故频道中。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="幂等性原本就很复杂现在它变成了两个幂等性问题">幂等性原本就很复杂；现在它变成了两个幂等性问题<a href="https://tianpan.co/zh/blog/2026-06-03-the-async-tool-call-that-resolved-after-the-user-closed-the-conversation#%E5%B9%82%E7%AD%89%E6%80%A7%E5%8E%9F%E6%9C%AC%E5%B0%B1%E5%BE%88%E5%A4%8D%E6%9D%82%E7%8E%B0%E5%9C%A8%E5%AE%83%E5%8F%98%E6%88%90%E4%BA%86%E4%B8%A4%E4%B8%AA%E5%B9%82%E7%AD%89%E6%80%A7%E9%97%AE%E9%A2%98" class="hash-link" aria-label="幂等性原本就很复杂；现在它变成了两个幂等性问题的直接链接" title="幂等性原本就很复杂；现在它变成了两个幂等性问题的直接链接" translate="no">​</a></h2>
<p>生产环境中的重试场景通常假设是 <em>系统</em> 进行重试，而不是 <em>会话</em> 进行重试。像 Temporal、Restate 或 LangGraph 的检查点层（checkpointing layer）这样的持久执行引擎，通过记录每一步的日志并使用幂等键进行重放，来防止 worker 崩溃和工具的不稳定性，从而确保已完成的工作不会重复执行。这之所以有效，是因为工作流运行（workflow run）是身份的单位，而幂等键是由工作流运行 ID 与步骤结合生成的。</p>
<p>异步工具回调（async-tool-callback）是另一个正交的问题。这不再是系统重试同一个工作流；而是 <em>用户</em> 放弃了当前工作流，并在旧工作流完成之前启动了一个新工作流。由于新的工作流运行具有不同的 ID，工具服务无法知道新运行与旧运行其实是同一个用户想要同样的结果，因此幂等键无法起到去重的作用。</p>
<p>两个失败场景，都戴着“重试”的帽子：</p>
<ul>
<li class=""><strong>引擎级重试</strong>：worker 崩溃，工作流恢复，同一步骤需要从日志重放或通过幂等性重新执行。由持久执行引擎解决。已被广泛理解。</li>
<li class=""><strong>用户级重试</strong>：对话过期，用户重新开始，前一次运行的工具结果现在成了一个寻找归属的残留产物（artifact）。持久执行引擎 <em>无法</em> 解决此问题。通常没有任何方案能解决。</li>
</ul>
<p>如果你的工具服务构建得很好，它会有一个源自运行 ID 和步骤的幂等键。如果引擎重试，该键可以保护你免受重复执行的影响。但它无法保护你免受用户启动新对话并重新发出相同逻辑请求的影响——对于工具服务来说，那是两个不同的键和两次不同的调用，两者都会执行。第二次调用可能会在第一次调用即将成功时生效。第一次调用也可能在第二次调用已经确定结果后才完成。用户看到一个答案；现实世界却看到了两个副作用。</p>
<p>最干净的修复方法是从对 <em>意图</em>（intent）保持稳定的事物——用户、工具和输入——中派生出幂等键，而不是从对话运行中派生。这要求工具层知道是哪个用户在调用（大多数工具层都知道），并接受同一个用户在某个时间窗口内使用相同的参数调用同一个工具是同一个逻辑请求。选择这个窗口是一个设计抉择。选得太窄会导致重复执行；选得太宽则会阻止用户合法地重新执行相同的工作。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="工具需要的是可逆性层级而不仅仅是幂等键">工具需要的是可逆性层级，而不仅仅是幂等键<a href="https://tianpan.co/zh/blog/2026-06-03-the-async-tool-call-that-resolved-after-the-user-closed-the-conversation#%E5%B7%A5%E5%85%B7%E9%9C%80%E8%A6%81%E7%9A%84%E6%98%AF%E5%8F%AF%E9%80%86%E6%80%A7%E5%B1%82%E7%BA%A7%E8%80%8C%E4%B8%8D%E4%BB%85%E4%BB%85%E6%98%AF%E5%B9%82%E7%AD%89%E9%94%AE" class="hash-link" aria-label="工具需要的是可逆性层级，而不仅仅是幂等键的直接链接" title="工具需要的是可逆性层级，而不仅仅是幂等键的直接链接" translate="no">​</a></h2>
<p>幂等性告诉你两次执行调用是否安全。可逆性（Reversibility）告诉你，一旦会话断开连接，执行该调用是否依然安全。</p>
<p>读取操作显然是安全的。结果无处可去的读取可以直接丢弃——其副作用为零。写入分为两个层级：可逆写入（副作用在结果没有消费者时可以撤销，例如保存的草稿、可以拆除的幂等配置步骤）和单向写入（无论谁在听，副作用都会持久存在，例如发送的邮件、发布的消息、扣费、删除的行）。</p>
<p>Agent 的规划器（planner）天生没有这种区分的概念；函数调用规范（function-calling schema）也没有对其命名。运行层必须具备这种能力。在调度工具之前，网关需要知道如果工具完成时会话已经消失会发生什么：</p>
<ul>
<li class=""><strong>可取消 / 可逆</strong>：当会话过期时，向工具服务发出“连接断开时取消”信号；忽略延迟的结果。</li>
<li class=""><strong>幂等且持久</strong>：将结果保存在一个稳定的“用户与意图”键下；将其交付给下一个匹配的会话；在用户下一次对话的第一轮向其展示结转的结果。</li>
<li class=""><strong>单向且不可逆</strong>：不要在可能在工具完成前崩溃的会话边界上调度；需要一个独立的确认界面（通知、电子邮件、专门的任务列表），以便结果有一个不依赖于会话保持开启的归宿。</li>
</ul>
<p>第三层是大多数团队跳过的层级，因为同步聊天 UI 没有它的位置。聊天是唯一的界面。增加任务列表、通知频道或“你的渲染已完成”界面，意味着将长时运行的工具视为状态单位，而不是将对话视为状态单位。这是持久执行社区两年来一直在推动的架构转型，而大多数 Agent 前端仍未实现这一点。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="会话寿命应该是-agent-能调度的最慢工具的函数">会话寿命应该是 Agent 能调度的最慢工具的函数<a href="https://tianpan.co/zh/blog/2026-06-03-the-async-tool-call-that-resolved-after-the-user-closed-the-conversation#%E4%BC%9A%E8%AF%9D%E5%AF%BF%E5%91%BD%E5%BA%94%E8%AF%A5%E6%98%AF-agent-%E8%83%BD%E8%B0%83%E5%BA%A6%E7%9A%84%E6%9C%80%E6%85%A2%E5%B7%A5%E5%85%B7%E7%9A%84%E5%87%BD%E6%95%B0" class="hash-link" aria-label="会话寿命应该是 Agent 能调度的最慢工具的函数的直接链接" title="会话寿命应该是 Agent 能调度的最慢工具的函数的直接链接" translate="no">​</a></h2>
<p>在大多数技术栈中，默认的对话 TTL 是由产品直觉设定的——聊天几分钟，助手几小时，项目类工作几天；而工具超时是由运维直觉设定的——只要能让慢速工具在负载下不再报错就行。这两个数字几乎从未被放在一起考虑。所有有趣的失败案例都存在于它们之间的缝隙中。</p>
<p>一个有用的不变量：<strong>会话状态的寿命必须超过该会话中 Agent 可能调度的任何工具在最坏情况下的完成时间。</strong> 否则，你将面临一个必然的孤儿率，其比例等于超过会话 TTL 的工具运行比例。除非你专门统计“交付给已过期对话的工具结果”，否则这个孤儿率在现有仪表盘中是不可见的——而大多数团队并不会这样做。</p>
<p>这个不变量说起来容易做起来难。24 小时的会话 TTL 在数据库行数方面很便宜，但在上下文变得过时方面却很昂贵。允许 Agent 调度需要一天才能完成的工具，会迫使会话状态层的寿命远远超过用户的注意力。诚实的做法是拆分状态模型：用于聊天体验的短效内存对话状态，用于 Agent 循环及其未完成工具调用的长效持久运行状态，以及在结果落地时协调两者的交付层。</p>
<p>一旦两个状态层分离，“用户关闭对话时会发生什么”就变成了一个简单的路由决策，而不是一个数据丢失事件。Agent 运行会针对持久状态继续进行。工具结果会根据运行的稳定 ID 落地。当用户回来时——无论是同一个会话还是新会话——运行时会询问 Agent 循环是否有待处理的完成结果需要呈现，答案要么是“是的，这是你昨天开始的渲染”，要么是“不，你关心的所有事情都在你离开期间处理完了”。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="你最后才会察觉的失效模式">你最后才会察觉的失效模式<a href="https://tianpan.co/zh/blog/2026-06-03-the-async-tool-call-that-resolved-after-the-user-closed-the-conversation#%E4%BD%A0%E6%9C%80%E5%90%8E%E6%89%8D%E4%BC%9A%E5%AF%9F%E8%A7%89%E7%9A%84%E5%A4%B1%E6%95%88%E6%A8%A1%E5%BC%8F" class="hash-link" aria-label="你最后才会察觉的失效模式的直接链接" title="你最后才会察觉的失效模式的直接链接" translate="no">​</a></h2>
<p>这个问题的复盘分析版本几乎总是围绕“跨会话泄露”案例展开，因为这种案例才会被上报。随着时间的推移，耗费成本更高的是“静默丢弃”的情况——工具运行完成，副作用已在现实中产生，但用户从未被告知。你支付了工具费用，承担了副作用成本，却得到了零转化归因，因为用户从未看到答案。</p>
<p>捕获这一情况的检测手段并不华丽：对于发出的每一个工具调用，记录其完成时间以及完成时是否挂载了会话。计算比例。如果“完成时脱离”占总长时调用的比例超过几个百分点，那么你面临的是架构问题，而不是告警问题。解决方法不是设置更响亮的警报，而是上文所述的状态分离模型和交付层。</p>
<p>你发起的异步工具调用终将完成。有趣的问题在于，到那时你的系统是否有地方可以存放结果。</p>]]></content>
        <author>
            <name>Tian Pan</name>
            <uri>https://tianpan.co</uri>
        </author>
        <category label="ai-agents" term="ai-agents"/>
        <category label="tool-calling" term="tool-calling"/>
        <category label="async" term="async"/>
        <category label="distributed-systems" term="distributed-systems"/>
        <category label="session-management" term="session-management"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[金丝雀群组：按 ID 哈希的分流如何将核心用户聚集到同一实验组]]></title>
        <id>https://tianpan.co/zh/blog/2026-06-03-the-canary-cohort-your-rollout-hashed-by-id-that-clustered-power-users-into-one-arm</id>
        <link href="https://tianpan.co/zh/blog/2026-06-03-the-canary-cohort-your-rollout-hashed-by-id-that-clustered-power-users-into-one-arm"/>
        <updated>2026-06-03T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[对不透明 ID 进行均匀哈希并不等同于对用户进行均匀抽样。当 ID 分配与参与度相关联时，基于哈希分桶的金丝雀发布可能会悄无声息地将所有核心用户分配到同一个实验组，并报告一个虚假的增长结果。]]></summary>
        <content type="html"><![CDATA[<p>一个发布团队在百分比旗标（percentage flag）的保护下发布了一个新模型。分桶计算公式为 <code>hash(user_id) % 100</code>，金丝雀（canary）测试覆盖 0–4 桶。在两周内，人均参与度的提升显著且稳定，于是团队将比例提升到 20%，随后是 50%，最后推向全球。在 50% 到全量发布的某个阶段，这种提升突然消失了。事后复盘（post-mortem）发现问题出在金丝雀人群（canary cohort）。实验变量并没有真正改变指标。金丝雀组的样本是一个特殊的群体。</p>
<p><img decoding="async" loading="lazy" src="https://opengraph-image.blockeden.xyz/api/og-tianpan-co?title=%E9%87%91%E4%B8%9D%E9%9B%80%E7%BE%A4%E7%BB%84%EF%BC%9A%E6%8C%89%20ID%20%E5%93%88%E5%B8%8C%E7%9A%84%E5%88%86%E6%B5%81%E5%A6%82%E4%BD%95%E5%B0%86%E6%A0%B8%E5%BF%83%E7%94%A8%E6%88%B7%E8%81%9A%E9%9B%86%E5%88%B0%E5%90%8C%E4%B8%80%E5%AE%9E%E9%AA%8C%E7%BB%84" alt="" class="img_ev3q"></p>
<p>团队以为自己是在对用户进行采样，实际上它是在对 ID 进行采样。</p>
<p>这两个词看起来可以互换，直到你意识到 ID 并非由用户生成。它们是由账户创建当天运行的任何系统生成的——可能是 Postgres 中的序列，或者是 Snowflake 风格的时间编码整数，或者是带有时间戳前缀的 UUIDv7。如果该生成器嵌入了时间，那么“ID 相邻的用户”实际上意味着“在同一时间段注册的用户”。而注册时间是大多数产品中衡量行为最强的预测因子之一。两年前引入一波核心用户潮的病毒式集成，占据了 ID 空间中为期六个月的时间窗口。任何不主动打乱该窗口的分桶方案，都可能将这批用户完整地划分到同一个实验组中。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="哈希函数完全是在执行你的指令">哈希函数完全是在执行你的指令<a href="https://tianpan.co/zh/blog/2026-06-03-the-canary-cohort-your-rollout-hashed-by-id-that-clustered-power-users-into-one-arm#%E5%93%88%E5%B8%8C%E5%87%BD%E6%95%B0%E5%AE%8C%E5%85%A8%E6%98%AF%E5%9C%A8%E6%89%A7%E8%A1%8C%E4%BD%A0%E7%9A%84%E6%8C%87%E4%BB%A4" class="hash-link" aria-label="哈希函数完全是在执行你的指令的直接链接" title="哈希函数完全是在执行你的指令的直接链接" translate="no">​</a></h2>
<p>残酷的是，哈希函数是无辜的。在稠密的 ID 空间上，一个优秀的哈希函数会产生 ID 的均匀分布。如果你检查分桶分配的边际分布，每个桶的数量大致相同，样本比率失配（SRM）的卡方检验结果正常，实验平台也会宣布流量分配是健康的。SRM 检查是检测分配链路是否损坏的正确工具——例如缺失的变体脚本、导致一半变体丢失的重定向、或者失效的定向规则——但它们比较的是每个桶的用户数量，而不是构成。数量相等但构成不等，是标准健康检查在设计上就会忽略的一类偏差。</p>
<p>哈希函数在不同实验中还会重复使用相同的摘要。如果两个并行实验使用了相同的盐值（salt）或相同的分区策略，产生的分配在单个实验中看起来是独立的，但在合并分析时会显示出相关性——这是一种已知的故障模式，通常在平台团队寻找虚假提升（phantom lifts）的来源时浮出水面。这种情况下的解决方法是为每个实验设置独立的盐值。而此案例中的解决方法则不同，因为问题不在于实验之间的冲突，而在于 ID 空间与人群空间（cohort space）之间的冲突。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="为什么id-均匀不等同于用户均匀">为什么“ID 均匀”不等同于“用户均匀”<a href="https://tianpan.co/zh/blog/2026-06-03-the-canary-cohort-your-rollout-hashed-by-id-that-clustered-power-users-into-one-arm#%E4%B8%BA%E4%BB%80%E4%B9%88id-%E5%9D%87%E5%8C%80%E4%B8%8D%E7%AD%89%E5%90%8C%E4%BA%8E%E7%94%A8%E6%88%B7%E5%9D%87%E5%8C%80" class="hash-link" aria-label="为什么“ID 均匀”不等同于“用户均匀”的直接链接" title="为什么“ID 均匀”不等同于“用户均匀”的直接链接" translate="no">​</a></h2>
<p>哈希分桶方案提供了一个隐含的契约：<em>如果我对唯一标识符进行哈希，我就能得到一个可交换的用户样本</em>。只有当标识符与你关心的变量在统计上相互独立时，这一约定才成立。对于一个没有信息内容的随机分配标识符，这是成立的。但对于一个随注册时间单调递增的整数，它就不成立了——标识符携带了时间协变量，任何与注册时间相关的群体行为都会渗透到分桶分配中。</p>
<p>最常见的版本是核心用户偏差（heavy-user bias）。微软研究院的论文《关于 A/B 测试中的核心用户偏差》（On Heavy-user Bias in A/B Testing）指出，通常只有极小比例的账户驱动了大部分指标的变化，而实验窗口内的核心用户构成可能与长期总体分布存在显著偏差。当核心用户在特定维度（注册时间窗、地理位置、订阅层级、设备）上聚集，且你的分桶函数对该维度敏感时，实验测量的就是与最终全量发布时不同的群体。这种偏差并不是因为实验运行时间太短产生的，而是由于分配方式在基于数量的健康检查无法察觉的情况下，呈现出非随机性。</p>
<p>注册时间人群是一个教科书般的案例。核心用户往往在特定时期集中注册：一次发布、一个媒体周期、一次病毒式集成或一次合作伙伴活动。这些爆发产生了密集的 ID 范围。一个将相邻 ID 映射到相邻分桶的哈希函数——许多实际应用中的哈希函数都是如此，特别是在分桶数量较少时的取模（modulo）或折叠混合（fold-and-mix）变体——会将连续的 ID 范围路由到连续的分桶范围。一个映射到 0–4 桶的 5% 金丝雀测试包含五个连续的分桶边界。从理论上讲，一个参与度极高的六个月注册窗口完全有可能全部落在执行这五个桶中。从概率上讲，平均而言这似乎不太可能发生，但“核心用户人群落在哪”在少量分桶中的方差足够大，以至于任何一次发布都可能撞上这种糟糕的安排。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="标准健康检查漏掉了什么">标准健康检查漏掉了什么<a href="https://tianpan.co/zh/blog/2026-06-03-the-canary-cohort-your-rollout-hashed-by-id-that-clustered-power-users-into-one-arm#%E6%A0%87%E5%87%86%E5%81%A5%E5%BA%B7%E6%A3%80%E6%9F%A5%E6%BC%8F%E6%8E%89%E4%BA%86%E4%BB%80%E4%B9%88" class="hash-link" aria-label="标准健康检查漏掉了什么的直接链接" title="标准健康检查漏掉了什么的直接链接" translate="no">​</a></h2>
<p>当实验组的数量与预期百分比偏差过大时，实验平台的 SRM 检测器就会报警。该检查可以捕捉到带有热点的哈希函数、在部分变体页面失效的跟踪脚本或导致流量丢失的重定向。但它无法捕捉到数量平衡的分组——即其中一个实验组的用户上个月在产品上的支出恰好是另一个组的 5 倍。</p>
<!-- -->
<div class="loading_VaNF">加载中…</div>]]></content>
        <author>
            <name>Tian Pan</name>
            <uri>https://tianpan.co</uri>
        </author>
        <category label="insider" term="insider"/>
        <category label="experimentation" term="experimentation"/>
        <category label="ab-testing" term="ab-testing"/>
        <category label="rollout" term="rollout"/>
        <category label="sampling-bias" term="sampling-bias"/>
        <category label="mlops" term="mlops"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[引用索引失效：当你的分块器开始添加行号前缀时，偏移了一位]]></title>
        <id>https://tianpan.co/zh/blog/2026-06-03-the-citation-index-your-chunker-shifted-by-one-when-it-started-prefixing-line-numbers</id>
        <link href="https://tianpan.co/zh/blog/2026-06-03-the-citation-index-your-chunker-shifted-by-one-when-it-started-prefixing-line-numbers"/>
        <updated>2026-06-03T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[一个文档分块器添加了 [第 N 行] 前缀，结果每个引用都指向了证据的前一个段落 —— 这种失效模式在于两个系统对整数的形式达成了一致，但在其含义上产生了分歧。本文将探讨如何在审计员发现之前捕获此类问题。]]></summary>
        <content type="html"><![CDATA[<p>分块器开始在每个块前添加 <code>[line N]</code>。Eval 变绿了（通过了）。从那天起，模型生成的每一条引用都指向了实际证据前的一个段落，这种情况出现在该产品所服务的受监管行业的每一份文档中。团队并不是通过评估发现这个问题的，而是通过一位审计人员发现的。审计人员查看了引用的句子，阅读后指出，该句子与其本应支持的断言完全矛盾。</p>
<p><img decoding="async" loading="lazy" src="https://opengraph-image.blockeden.xyz/api/og-tianpan-co?title=%E5%BC%95%E7%94%A8%E7%B4%A2%E5%BC%95%E5%A4%B1%E6%95%88%EF%BC%9A%E5%BD%93%E4%BD%A0%E7%9A%84%E5%88%86%E5%9D%97%E5%99%A8%E5%BC%80%E5%A7%8B%E6%B7%BB%E5%8A%A0%E8%A1%8C%E5%8F%B7%E5%89%8D%E7%BC%80%E6%97%B6%EF%BC%8C%E5%81%8F%E7%A7%BB%E4%BA%86%E4%B8%80%E4%BD%8D" alt="" class="img_ev3q"></p>
<p>这种回归错误（regression）能躲过代码审查、对三个示例文档的手动 QA 测试以及功能开关（feature-flag）的逐步推送。孤立地看，这些检查都没有错。它们都在问同一个问题——在预期的地方是否出现了引用——但没有一个检查在问审计人员问的问题，即：引用是否指向了断言来源的那个句子。这两个问题之间的差距，正是那个“差一错误”（off-by-one）长期潜伏的地方。</p>
<p>这种失效模式之所以值得专门写篇文章，不在于 Bug 本身。差一错误是陈年旧事了。有趣的地方在于，这个失效是由两个系统共同产生的：它们在整数的结构上保持一致，却在整数的含义上产生了无声的分歧。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="分块器和引用解析器从未在同一个频道上">分块器和引用解析器从未在同一个频道上<a href="https://tianpan.co/zh/blog/2026-06-03-the-citation-index-your-chunker-shifted-by-one-when-it-started-prefixing-line-numbers#%E5%88%86%E5%9D%97%E5%99%A8%E5%92%8C%E5%BC%95%E7%94%A8%E8%A7%A3%E6%9E%90%E5%99%A8%E4%BB%8E%E6%9C%AA%E5%9C%A8%E5%90%8C%E4%B8%80%E4%B8%AA%E9%A2%91%E9%81%93%E4%B8%8A" class="hash-link" aria-label="分块器和引用解析器从未在同一个频道上的直接链接" title="分块器和引用解析器从未在同一个频道上的直接链接" translate="no">​</a></h2>
<p>文档分块器输出块。引用提取器消费模型对这些块的引用，并将其解析回原始数据源中的片段。在大多数生产环境的 RAG 架构中，这两个组件由不同的团队负责，按不同的节奏部署，并由不同的评估套件进行测试。它们通过每个引用对应的一个整数进行通信——段落索引、行范围或块位置。</p>
<p>那个整数是一个坐标。坐标需要坐标系。分块器在一个坐标系中写入整数；解析器在另一个坐标系中读取它们；它们之间的契约是一种隐式协定，即双方都从同一个起点计算相同的东西。</p>
<p>当分块器在每个块前添加 <code>[line N]</code> 前缀以便模型能引用行范围而不是段落编号时，该前缀占用了块的第一行。分块器在存储层输出的索引没有变化。模型在读取带有前缀的块时，从前缀开始编号。引用解析器在解析模型输出的内容时，仍然通过添加前缀之前的段落索引进行映射。在模型的坐标系中，每个段落索引都偏移了一位，而在解析器的坐标系中偏移量为零，结果就变成了引用指向实际证据紧前方的段落。</p>
<p>没有代码路径抛出异常。没有正则表达式匹配失败。块的数量没有改变。两个系统在结构上保持兼容——相同的数据类型、相同的范围、相同的响应形状——而它们的语义一致性却悄然瓦解。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="引用存在不是一种引用指标">“引用存在”不是一种引用指标<a href="https://tianpan.co/zh/blog/2026-06-03-the-citation-index-your-chunker-shifted-by-one-when-it-started-prefixing-line-numbers#%E5%BC%95%E7%94%A8%E5%AD%98%E5%9C%A8%E4%B8%8D%E6%98%AF%E4%B8%80%E7%A7%8D%E5%BC%95%E7%94%A8%E6%8C%87%E6%A0%87" class="hash-link" aria-label="“引用存在”不是一种引用指标的直接链接" title="“引用存在”不是一种引用指标的直接链接" translate="no">​</a></h2>
<p>评估套件将引用评分为布尔值：模型是否生成了引用，以及引用是否解析到了语料库中的一个块？在这两个维度上，新的分块器都顺利通过了。每个回答都有引用。每个引用都能解析。如果说有什么变化的话，评估面板上的分数反而上升了，因为前缀起初给模型提供了一个更清晰的引用信号。</p>
<p>能捕捉到这个问题的指标不是“引用存在”，而是“引用正确”——定义为被引用片段与其支持的断言之间的语义匹配。计算引用正确性的成本要高得多。它需要知道答案中包含哪些原子断言、每个断言本应来自哪个片段，以及一个判断对齐是否成功的比较器。大多数团队并不维护这一指标。那些维护该指标的团队通常也只在很小的黄金集（golden set）上维护，而不会在足以检测单个子语料库内分布偏移的大样本上维护。</p>
<p>廉价的代理指标都会向同一个方向退化。在没有显式归因训练的情况下，生产环境 RAG 的引用准确率平均在 65–70% 左右，但这只是一个聚合值；它无法告诉你那 30% 的错误是否以相同的方式、在相同的文档上或在相同的部署后发生。差一错误是一种结构化的错误，而结构化错误正是会被聚合指标平滑掉的那种失效。</p>
<p>教训并不是说“引用存在”是一个坏指标。它是一个很好的冒烟测试（smoke test）。教训在于它仅仅是一个冒烟测试，而冒烟测试无法防御那些它未测量的方面的语义回归。将引用正确性视为一等指标——进行持续监测、针对变化斜率而非绝对值报警、针对已知答案的文档进行计算——是让差一错误在审计员发现之前就显形。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="两个在类型上达成一致却在含义上产生分歧的系统">两个在类型上达成一致，却在含义上产生分歧的系统<a href="https://tianpan.co/zh/blog/2026-06-03-the-citation-index-your-chunker-shifted-by-one-when-it-started-prefixing-line-numbers#%E4%B8%A4%E4%B8%AA%E5%9C%A8%E7%B1%BB%E5%9E%8B%E4%B8%8A%E8%BE%BE%E6%88%90%E4%B8%80%E8%87%B4%E5%8D%B4%E5%9C%A8%E5%90%AB%E4%B9%89%E4%B8%8A%E4%BA%A7%E7%94%9F%E5%88%86%E6%AD%A7%E7%9A%84%E7%B3%BB%E7%BB%9F" class="hash-link" aria-label="两个在类型上达成一致，却在含义上产生分歧的系统的直接链接" title="两个在类型上达成一致，却在含义上产生分歧的系统的直接链接" translate="no">​</a></h2>
<p>这里更深层的失效是类型（typing）问题。分块器输出一个整数作为段落索引。解析器接受一个整数作为段落索引。编译器、Lint 工具和类型检查器都予以认可。类型系统中没有任何东西指明“这两个整数必须处于同一个坐标系中”。</p>
<p>这与将“米”作为参数传递给预期“英尺”的函数是同类 Bug。函数会返回一个数字。这个数字将是错误的。你拥有的任何工具都不会告诉你，因为双方在数值的维度上达成了一致——仅仅在解释上存在分歧。</p>
<p>解决这一问题的模式是采用内容坐标类型。不要使用 <code>paragraph_index: int</code>，而是为索引方案命名：</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">ChunkPositionInPrefixedFrame</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">ChunkPositionInOriginalFrame</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">LineNumberInPrefixedFrame</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">SentenceIndexInChunk</span><br></div></code></pre></div></div>
<p>每一种都是独特的类型。它们之间的转换是显式的。分块器在一个框架中输出引用；解析器在针对源进行解析之前，显式地转换到另一个框架。这种偏移变成了一个团队必须编写、命名和审查的函数。对分块器框架的任何更改——前缀、页眉、结构化插入——都会强制要求对转换器进行相应的更改，因为转换器的类型签名发生了变化。</p>
<p>这并非罕见工程技术。这与金融代码用于货币、物理代码用于单位的幽灵类型（phantom-types）技巧如出一辙。RAG 代码很少使用它的原因是流经的数据“仅仅是文本”，而“仅仅是文本”让人觉得不需要类型系统。那个差一错误，就是为这种假设所支付的账单。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="分块格式的变更是坐标系统的变更">分块格式的变更是坐标系统的变更<a href="https://tianpan.co/zh/blog/2026-06-03-the-citation-index-your-chunker-shifted-by-one-when-it-started-prefixing-line-numbers#%E5%88%86%E5%9D%97%E6%A0%BC%E5%BC%8F%E7%9A%84%E5%8F%98%E6%9B%B4%E6%98%AF%E5%9D%90%E6%A0%87%E7%B3%BB%E7%BB%9F%E7%9A%84%E5%8F%98%E6%9B%B4" class="hash-link" aria-label="分块格式的变更是坐标系统的变更的直接链接" title="分块格式的变更是坐标系统的变更的直接链接" translate="no">​</a></h2>
<p>下一个模式是那种原本可以在自身部署时捕获回归（regression）的模式：一个专门针对引用解析器、使用已知答案的文档进行的回归测试，并在分块（chunk）格式的每次变更时运行。这指的不是评估套件（eval suite），而是一个针对“分块到引用”边界的定向契约测试（contract test）。</p>
<p>之所以将其作为独立测试，是因为从分块器（chunker）的角度看，分块格式的变更看起来很微小。增加一个前缀只需一行代码。对于同样的文档，分块器仍然输出相同数量的分块，具有大致相同的 token 预算。差异（diff）是局部的，但影响范围（爆炸半径）却是全局的。</p>
<p>在“分块到引用”边界进行的回归测试会选取一个已知答案的文档，将其运行完整流程，并检查引用是否解析到了包含该答案的跨度（span）。测试的目标不是“出现了一个引用”，也不是“引用解析成功”，而是解析出的跨度确实包含了目标文本。这只需要两三个文档和一个固件文件（fixture file）。这是能捕获功能标志（feature-flag）PR 中回归问题的最低成本投资。</p>
<p>如果团队还在旧分块器下运行相同的测试作为基准对比（baseline diff），那么新分块器上引用准确率的变化将表现为一个明显的增量（delta）。旧路径上 100% 的引用目标准确率与新路径上 0% 的准确率对比，是一个非常强烈的信号。功能标志之所以能发布，是因为没有人去查看那个正确的指标。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="审计捕获了评估所忽视的重点">审计捕获了评估所忽视的重点<a href="https://tianpan.co/zh/blog/2026-06-03-the-citation-index-your-chunker-shifted-by-one-when-it-started-prefixing-line-numbers#%E5%AE%A1%E8%AE%A1%E6%8D%95%E8%8E%B7%E4%BA%86%E8%AF%84%E4%BC%B0%E6%89%80%E5%BF%BD%E8%A7%86%E7%9A%84%E9%87%8D%E7%82%B9" class="hash-link" aria-label="审计捕获了评估所忽视的重点的直接链接" title="审计捕获了评估所忽视的重点的直接链接" translate="no">​</a></h2>
<p>评估（eval）本身存在选择偏差。它往往是基于团队能想到的查询、选择的文档以及早已知晓的答案构建的。相比之下，审计员运行的是一种对抗性工作流：获取一个引用，追踪它，阅读句子，并询问它是否支持该断言。这种工作流不会出现在任何标准的评估框架中，因为它是那些试图证伪模型断言的人的工作流，而不是试图验证模型行为的人的工作流。</p>
<p>将审计员的工作流引入评估循环的模式并非新概念，它们包括：</p>
<ul>
<li class=""><strong>原子断言分解（Atomic-claim decomposition）</strong>：将模型的回答分解为最小的独立断言。独立对每个断言与其引用进行评分。这可以捕获引用仅支持部分回答而反驳另一部分的情况。</li>
<li class=""><strong>跨度内容验证（Span-content verification）</strong>：在将引用解析为跨度后，运行跨度文本与断言文本之间的蕴含检查（entailment check）。即使索引解析正确，语义不匹配也属于引用失败。</li>
<li class=""><strong>对抗性采样（Adversarial sampling）</strong>：从审计员风格的工作流中抽取查询样本：高严重性的工单、受监管行业的边缘案例、以及客户提到其团队反复出错的特定问题类型的流失访谈。评估集应向最不可能流失的人群演进。</li>
<li class=""><strong>按功能标志对比（Per-feature-flag comparison）</strong>：当分块器的变更隐藏在标志后发布时，评估应针对同一组查询运行两个分支，并报告并排的增量。推向生产环境的要求是每个指标上的增量都必须持平或为正，而不仅仅是总体指标。</li>
</ul>
<p>这四个模式结合起来，就能在审计员发现之前暴露那个“差一错误”（off-by-one）。从绝对意义上讲，这些都不昂贵。它们之所以不是标准做法，是因为每一项都归属于不同的团队——原子断言由评估团队负责，跨度验证由检索团队负责，采样由产品分析团队负责，功能标志对比由基础设施团队负责。没有人端到端地负责“引用正确性”这一契约。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="正确只是巧合意味着什么">“正确只是巧合”意味着什么<a href="https://tianpan.co/zh/blog/2026-06-03-the-citation-index-your-chunker-shifted-by-one-when-it-started-prefixing-line-numbers#%E6%AD%A3%E7%A1%AE%E5%8F%AA%E6%98%AF%E5%B7%A7%E5%90%88%E6%84%8F%E5%91%B3%E7%9D%80%E4%BB%80%E4%B9%88" class="hash-link" aria-label="“正确只是巧合”意味着什么的直接链接" title="“正确只是巧合”意味着什么的直接链接" translate="no">​</a></h2>
<p>RAG 系统中的引用链是一系列引用关系。模型输出一个引用标识，解析器将标识解析为分块，分块映射回源文档中的跨度，用户阅读该跨度。每个环节都有自己的有效性检查，但这些检查都不能证明整条链条的正确性。</p>
<p>分块器发布了一个变更，破坏了其中一个环节，却让其他所有环节依然保持有效。模型仍然输出了引用，解析器仍然解析了它，跨度仍然渲染了，用户仍然阅读了它。审计是数月来第一次真正根据断言的含义端到端走完这条链条的检查，结果发现在新分块器生成的文档中，100% 的案例都在第一个环节就失败了。</p>
<p>事后分析中有一句话最应该让团队感到警惕：只要没有任何变动，引用就是正确的。这与“引用是正确的”并不是一回事。这只是一个巧合。两个独立的系统恰好对整数 <code>1</code> 指代的对象达成了一致。一旦其中一个系统切换了其框架，这种一致性就烟消云散了，而唯一能察觉到这一点的检查却是没人运行的那一个。</p>
<p>弥合这一差距的系统思维并不需要英雄主义。为你的坐标系统命名。在边界处测试你的契约，而不是在端点。对用户阅读的内容评分，而不是对模型输出的内容评分。在审计员在客户的季度回顾中运行审计工作流之前，先在你的评估循环中运行它。</p>
<p>比起受监管行业的客户带着监管机构和你模型的输出走进会议室的那种失败，指向矛盾断言的引用是代价最小的逃逸。那场会议的成本，就是你应该愿意花在第二个测试上的预算。</p>]]></content>
        <author>
            <name>Tian Pan</name>
            <uri>https://tianpan.co</uri>
        </author>
        <category label="rag" term="rag"/>
        <category label="citations" term="citations"/>
        <category label="evals" term="evals"/>
        <category label="chunking" term="chunking"/>
        <category label="observability" term="observability"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[引用链接依然有效，但内容已不再是模型引用的原文]]></title>
        <id>https://tianpan.co/zh/blog/2026-06-03-the-citation-url-that-resolved-but-no-longer-said-what-the-model-quoted</id>
        <link href="https://tianpan.co/zh/blog/2026-06-03-the-citation-url-that-resolved-but-no-longer-said-what-the-model-quoted"/>
        <updated>2026-06-03T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[一个 RAG 引用虽然通过了链接检查，但在审计中依然失败了，因为可访问性并不等于保真度。本文将探讨如何对引用的内容片段进行快照、哈希和保留，从而使记录在原文档被修改后依然能够存续。]]></summary>
        <content type="html"><![CDATA[<p>一个 RAG 智能体用一段简洁的文字和一条引用回答了客户的监管问题。验证层获取了该 URL，看到返回码为 <code>200 OK</code>，勾选通过并发布。六个月后，合规性审计调取了对话记录，点击同一个链接，却发现页面现在的内容与智能体引用的完全相反。URL 没问题，对话记录中的引用也没问题，但两者不再匹配。客户的合规官询问智能体是否捏造了引用，而团队无法证明它没有捏造，因为证明该 URL 过去内容的唯一证据就是智能体自己声称它说过什么。</p>
<p><img decoding="async" loading="lazy" src="https://opengraph-image.blockeden.xyz/api/og-tianpan-co?title=%E5%BC%95%E7%94%A8%E9%93%BE%E6%8E%A5%E4%BE%9D%E7%84%B6%E6%9C%89%E6%95%88%EF%BC%8C%E4%BD%86%E5%86%85%E5%AE%B9%E5%B7%B2%E4%B8%8D%E5%86%8D%E6%98%AF%E6%A8%A1%E5%9E%8B%E5%BC%95%E7%94%A8%E7%9A%84%E5%8E%9F%E6%96%87" alt="" class="img_ev3q"></p>
<p>这不是通常意义上的幻觉。模型检索到了真实内容，忠实地提取了真实的句子，并给出了一个至今仍可解析的真实 URL。世界上任何链接检查工具都会认为这个引用是有效的。然而，审计依然失败了，因为验证层衡量的是错误的属性。可访问性（Reachability）并不等同于忠实度（Fidelity）。URL 只是指向受他人编辑控制的可变文档的指针，一旦文档发生变化，每一份引用它的对话记录都会变成一个随时可能爆发的“幻觉报告”。</p>
<p>2016 年的一项学术引用研究发现，学术出版物中大约四分之三的 URI 引用所指向的内容在引用后发生了实质性变化。这个数字比大语言模型（LLM）早了五年。如果再加上一个每天向成千上万名客户引用这些 URL 的智能体，审计轨迹的腐烂速度将与开放网络的速度同步——也就是说，比你制定的数据留存策略失效得更快。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="url-耐久性不等于来源耐久性">URL 耐久性不等于来源耐久性<a href="https://tianpan.co/zh/blog/2026-06-03-the-citation-url-that-resolved-but-no-longer-said-what-the-model-quoted#url-%E8%80%90%E4%B9%85%E6%80%A7%E4%B8%8D%E7%AD%89%E4%BA%8E%E6%9D%A5%E6%BA%90%E8%80%90%E4%B9%85%E6%80%A7" class="hash-link" aria-label="URL 耐久性不等于来源耐久性的直接链接" title="URL 耐久性不等于来源耐久性的直接链接" translate="no">​</a></h2>
<p>大多数团队交付的验证层将引用视为 (声明, URL) 的二元组，并通过获取 URL 并检查响应状态码来验证。这是一个范畴错误。声明是对文档在特定时间点的表述。URL 则是一个指向当前位于该位置的任何内容的名称。两者在引用时相关，此后永远脱节。</p>
<p>在这种混淆中隐藏着三种失效模式。页面在原位被静默编辑，没有版本标识，且 URL 持续可解析——这是大多数新闻网站、监管门户和公司控制的文档的标准编辑流程。页面被移动，重定向返回了相同逻辑名称下的另一个文档——这在 CMS 迁移和收购中很常见。页面被删除并替换为软 404（返回 <code>200 OK</code> 但不包含原始声明）——这在撤回内容是核心目的的合规场景中非常普遍。</p>
<p>在这三种情况下，验证检查都会通过。但被引用的声明已不再存在于引用的位置。对话记录是唯一记录了模型认为它在引用什么的产物，而对话记录的权威性正是争议所在。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="为什么审计框架让情况变得更糟">为什么审计框架让情况变得更糟<a href="https://tianpan.co/zh/blog/2026-06-03-the-citation-url-that-resolved-but-no-longer-said-what-the-model-quoted#%E4%B8%BA%E4%BB%80%E4%B9%88%E5%AE%A1%E8%AE%A1%E6%A1%86%E6%9E%B6%E8%AE%A9%E6%83%85%E5%86%B5%E5%8F%98%E5%BE%97%E6%9B%B4%E7%B3%9F" class="hash-link" aria-label="为什么审计框架让情况��变得更糟的直接链接" title="为什么审计框架让情况变得更糟的直接链接" translate="no">​</a></h2>
<p>在低风险的消费级产品中，引用漂移（citation drift）只是一个用户体验问题：用户点击链接，发现内容与智能体说的不符，然后耸耸肩。但在受监管的环境中，这种不对称性是残酷的。构建智能体的团队有动力展示引用。而审计智能体的团队——有时是同一团队的合规对口部门，有时是监管机构，有时是客户的法律部门——则有动力根据实时源进行验证，因为那是唯一真实性独立于智能体声明的来源。</p>
<p>审计员的合理立场是：与被引来源不匹配的引用就是幻觉的证据。而团队的立场——“来源过去确实说过我们引用的内容”——则是一个没有任何证据支持的说法，除了对话记录本身，而这正是受调查的对象。团队每次都会输掉这场辩论，不是因为智能体捏造了引用，而是因为团队的验证层从未保存过唯一能闭环的证据。</p>
<p>更深层的问题在于，团队的合规姿态建立在一个无法经受开放网络考验的假设之上：引用的指代对象在对话记录的生命周期内是稳定的。没有人写下这个假设。也没有人评估过如果这个假设破裂会付出多少代价。与客户的合同规定智能体的回答是可审计的。而架构在没有告知任何人的情况下，悄然继承了开放网络的可变性。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="引用时快照而非审计时快照">引用时快照，而非审计时快照<a href="https://tianpan.co/zh/blog/2026-06-03-the-citation-url-that-resolved-but-no-longer-said-what-the-model-quoted#%E5%BC%95%E7%94%A8%E6%97%B6%E5%BF%AB%E7%85%A7%E8%80%8C%E9%9D%9E%E5%AE%A1%E8%AE%A1%E6%97%B6%E5%BF%AB%E7%85%A7" class="hash-link" aria-label="引用时快照，而非审计时快照的直接链接" title="引用时快照，而非审计时快照的直接链接" translate="no">​</a></h2>
<p>以最低架构成本实现最大效果的修复方案是：在引用发生的时刻抓取被引内容，并将其与响应一同存储。当智能体发出引用时，系统获取引用来源的具体段落（或更大范围的内容），对其进行哈希处理，并将内容和哈希值与对话记录一起持久化。审计对比的对象将是对话记录中的引用与快照，而不是对话记录中的引用与 URL 今天提供的内容。</p>
<p>这种模式已有先例。Memento 协议是一种自 2009 年起就存在、作为 Wayback Machine 基础的 HTTP 扩展，它正式区分了 URI-R（原始资源，可变的）和 URI-M（纪念物，资源在特定日期的固定版本）。指向 URI-M 的引用具有耐久性，而指向 URI-R 的引用则没有。大多数 RAG 系统发出 URI-R，因为那是源页面提供的内容。在引用时将 URI-R 与私有快照配对的系统，实际上是在内部实现 Memento 协议在机构层面所做的事情：将引用锚定在一个时刻。</p>
<p>有三点实现注意事项。第一，只快照包含声明的最小范围，而不是整个页面——页面级快照会增加存储压力，并因包含模型从未参考的内容而使审计复杂化。第二，使用内容寻址方案对快照进行哈希处理，以便审计能证明持有快照的团队没有对其进行篡改。第三，快照现在属于敏感数据——如果源页面有付费墙、版权保护或包含个人身份信息（PII），团队就吸收了之前不曾面临的内容许可或隐私问题。请为此做好规划。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="将漂移检测作为一等任务">将漂移检测作为一等任务<a href="https://tianpan.co/zh/blog/2026-06-03-the-citation-url-that-resolved-but-no-longer-said-what-the-model-quoted#%E5%B0%86%E6%BC%82%E7%A7%BB%E6%A3%80%E6%B5%8B%E4%BD%9C%E4%B8%BA%E4%B8%80%E7%AD%89%E4%BB%BB%E5%8A%A1" class="hash-link" aria-label="将漂移检测作为一等任务的直接链接" title="将漂移检测作为一等任务的直接链接" translate="no">​</a></h2>
<p>在引用时进行快照解决了审计问题，但它并不能解决面向用户的问题：如果客户今天点击引用链接，发现内容与智能体（agent）当时所说的不同。为此，一种定期重新抓取引用并将其与存储的快照进行对比的重新验证任务（re-verification job）才是正确的原语（primitive）。</p>
<p>该任务的输出并非简单的二元通过/失败，而是一个包含几个有用层级的漂移信号：</p>
<ul>
<li class=""><strong>完全一致 (Identical)</strong>：源内容与快照逐字节匹配。引用的持久性与底层页面一致。</li>
<li class=""><strong>表面漂移 (Cosmetic drift)</strong>：空格、格式或周围的模板内容发生了变化；引用的片段保持完整。引用仍然值得信赖。</li>
<li class=""><strong>实质性漂移 (Material drift)</strong>：引用的片段已被修改、移动或删除。在 URL 中已无法找到对话记录中的引用。面向用户的 UI 应当显示“自生成此回答以来，来源已发生变化”的警告，理想情况下还应附带智能体实际引用时的快照链接。</li>
<li class=""><strong>反转 (Reversal)</strong>：引用的片段已被编辑，其表达的意思发生了改变，从而改变了主张的含义。这是审计事故的情况。该对话记录应标记为待审查，任何由此响应派生的下游产物（如摘要、建议、合同条款）都应重新评估。</li>
</ul>
<p>如果一个团队每晚对引用索引运行此任务，就能持续获得信号，了解他们引用的语料库中有多少正在发生“侵蚀”。该信号也是采购决策的输入——漂移率高的来源，智能体应当减少依赖，或者在引用时明确说明注意事项。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="数据保留与主张的生命周期">数据保留与主张的生命周期<a href="https://tianpan.co/zh/blog/2026-06-03-the-citation-url-that-resolved-but-no-longer-said-what-the-model-quoted#%E6%95%B0%E6%8D%AE%E4%BF%9D%E7%95%99%E4%B8%8E%E4%B8%BB%E5%BC%A0%E7%9A%84%E7%94%9F%E5%91%BD%E5%91%A8%E6%9C%9F" class="hash-link" aria-label="数据保留与主张的生命周期的直接链接" title="数据保留与主张的生命周期的直接链接" translate="no">​</a></h2>
<p>快照模式迫使人们思考的架构问题，也是团队最不愿回答的问题：快照应该保留多久？引用有一个自然的保留期——即任何引用它们的响应的生命周期。如果团队为了满足监管机构的要求而将对话记录保留七年，那么引用的快照也必须保留七年。如果一个团队在引用时进行快照，但按 30 天滚动窗口清理快照，那么他们建立的审计追踪将按照存储预算的速度、而非按照履行义务的速度衰减。</p>
<p>如果采购合同规定智能体的输出是可审计的，那么隐含地，它也是一份针对审计所需每项证据的保留合同。团队的数据工程师和合规官通常还没有进行过这种沟通。这种问题第一次出现通常是在存储账单超过模型账单时，财务团队会问，为什么一个聊天机器人要为一个文档仓库付费。坦率地说，答案是：从聊天机器人提供引用的那一刻起，它就继承了监管机构的证据保留要求。存储成本就是能够为答案进行辩护的成本。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="引用是关于某一时刻的主张">引用是关于某一时刻的主张<a href="https://tianpan.co/zh/blog/2026-06-03-the-citation-url-that-resolved-but-no-longer-said-what-the-model-quoted#%E5%BC%95%E7%94%A8%E6%98%AF%E5%85%B3%E4%BA%8E%E6%9F%90%E4%B8%80%E6%97%B6%E5%88%BB%E7%9A%84%E4%B8%BB%E5%BC%A0" class="hash-link" aria-label="引用是关于某一时刻的主张的直接链接" title="引用是关于某一时刻的主张的直接链接" translate="no">​</a></h2>
<p>架构上的共识应该是，大语言模型（LLM）响应中的引用并不是指向来源的指针。它是关于该来源在特定时刻所说内容的主张，只是为了方便起见附加了一个 URL。URL 是该主张中最容易验证的部分，也是信息量最少的部分。困难的部分——也是验证层真正需要做的——是以一种在来源编辑后仍能存续的形式，保存赋予该主张意义的内容。</p>
<p>将 URL 视为引用单元的团队，其发布的审计追踪的半衰期取决于他们引用的每个站点的编辑频率。而将引用的片段视为引用单元、在引用时进行快照、对其进行哈希处理并随对话记录一同保留的团队，则发布了一个比来源更长久的审计追踪。在引用发出的那天，这两者的区别是不可见的。但在审计降临的那天，这就是唯一重要的事情。</p>
<p>修复成本并不高。昂贵的是必须先进行的对话：即“智能体引用其来源”这一架构意图，是团队代表开放互联网做出的一项承诺，而开放互联网并未同意受此约束。在审计降临前弥合这一差距的团队，构建了一个名副其实的系统。而没有这样做的团队，则构建了一个尚未触发的幻觉报告。</p>]]></content>
        <author>
            <name>Tian Pan</name>
            <uri>https://tianpan.co</uri>
        </author>
        <category label="rag" term="rag"/>
        <category label="llm" term="llm"/>
        <category label="citation-drift" term="citation-drift"/>
        <category label="audit" term="audit"/>
        <category label="compliance" term="compliance"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[你的编程代理基于落后 Main 分支三周的代码版本重建的代码库索引]]></title>
        <id>https://tianpan.co/zh/blog/2026-06-03-the-codebase-index-your-coding-agent-rebuilt-from-a-checkout-three-weeks-behind-main</id>
        <link href="https://tianpan.co/zh/blog/2026-06-03-the-codebase-index-your-coding-agent-rebuilt-from-a-checkout-three-weeks-behind-main"/>
        <updated>2026-06-03T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[当编程代理的语义索引与其工作树发生漂移时，代理会基于已不存在的代码提出自信的主张 —— 而这种失败模式往往隐藏在看似平常的 PR 之中。]]></summary>
        <content type="html"><![CDATA[<p>你团队中的一个 AI 编程 Agent 提交了一个 PR，在两个文件中调用了四次 <code>parseUserToken()</code>。这个函数在代码仓库中并不存在，甚至已经消失了 19 天，早在你团队所有工程师都记得评审过的一次提交中就被 <code>decodeSessionClaim()</code> 替换了。Agent 并不是凭空捏造了这个名字，它是从其语义索引中读取的——那个向量库是从一个比 <code>main</code> 分支落后 21 天的工作副本重建的。相比之下，Agent 的编辑步骤在会话开始时运行了 <code>git pull</code>，操作的是最新的代码。对同一个代码库的两个视角，相隔三周，而 Agent 却自信地用一段无法针对任何真实环境编译的代码桥接了它们。</p>
<p><img decoding="async" loading="lazy" src="https://opengraph-image.blockeden.xyz/api/og-tianpan-co?title=%E4%BD%A0%E7%9A%84%E7%BC%96%E7%A8%8B%E4%BB%A3%E7%90%86%E5%9F%BA%E4%BA%8E%E8%90%BD%E5%90%8E%20Main%20%E5%88%86%E6%94%AF%E4%B8%89%E5%91%A8%E7%9A%84%E4%BB%A3%E7%A0%81%E7%89%88%E6%9C%AC%E9%87%8D%E5%BB%BA%E7%9A%84%E4%BB%A3%E7%A0%81%E5%BA%93%E7%B4%A2%E5%BC%95" alt="" class="img_ev3q"></p>
<p>这是一种不会自我宣告的失败模式。Agent 运行了。测试看起来通过了。PR 合并了。第一位评审者之所以注意到，仅仅是因为一个被删减的函数与一个无关的辅助函数重名，触发了 linter 报错。到那时，Agent 已经花了一个完整的冲刺（sprint）针对一个“幻影版本”的代码库进行编写，而团队中没有一个人——包括 Agent 自己——收到任何异常信号。</p>
<p>Agent 对代码库的“理解”与代码库的“实际状态”之间的缝隙，是一个没人会在架构图上画出来的“一致性边界”。索引由一个团队按一种节奏更新；工作树（working tree）由另一个团队按另一种节奏获取。Agent 的推理存在于第一个层面，而 Agent 的动作落在了第二个层面。当两者即便只差几次提交——更不用说三周——结果就是一个 Agent 会基于一个已不存在的代码库，提出极其自信的主张。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="偏差是如何产生的">偏差是如何产生的<a href="https://tianpan.co/zh/blog/2026-06-03-the-codebase-index-your-coding-agent-rebuilt-from-a-checkout-three-weeks-behind-main#%E5%81%8F%E5%B7%AE%E6%98%AF%E5%A6%82%E4%BD%95%E4%BA%A7%E7%94%9F%E7%9A%84" class="hash-link" aria-label="偏差是如何产生的的直接链接" title="偏差是如何产生的的直接链接" translate="no">​</a></h2>
<p>索引和工作树是平台回答“这个仓库里有什么代码”的两种方式。大多数 Agent 平台将它们存放在不同的地方，按不同的时间表刷新，并假设结果是一致的。事实往往并非如此。</p>
<p>最常见的偏差来源是镜像缓存（mirror cache）。为了应对 GitHub 的速率限制并加速冷克隆（cold clones），Agent 平台会为每个跟踪的仓库保留一个本地镜像，并定期（每隔几分钟、每小时甚至更久）从 <code>origin</code> 刷新。多年来，这种镜像一直是与 CDN 类似的基础设施；像 <code>gitcache</code> 和 <code>git-cache-clone</code> 这样的工具对此有明确说明。刷新间隔是可配置的，而问题正出在这里。如果刷新任务在平台迁移期间被暂停、限速、误配置或遗忘，镜像就会在无形中落后于 <code>origin</code>。索引从镜像中提取，而工作树直接从 <code>origin</code> 提取。两者因此产生分歧。</p>
<p>其他诱因则更为微妙。出于性能考虑，语义索引在不同会话之间是持久化的——重新计算嵌入（embeddings）成本高昂，大多数 Agent 平台都会积极复用它们。在大规模分支切换、重写了数千个文件的依赖项升级、生成的代码重新生成或 LFS 拉取之后，索引可能会携带指向已不存在的代码的孤立条目。Cursor 的文档承认了这一点：当自动补全开始引用你三个分支前删除的文件时，说明索引中存在陈旧条目。Cursor 用来增量重新索引的 Merkle 树变化检测器在查找“已修改”文件方面效率很高，但在结构性重写时，它的失效处理并不总是足够激进。</p>
<p>第三个诱因是索引失败但检索未失败。索引器在运行中途崩溃，留下了一个不完整的索引，而 Agent 平台继续根据最后一次索引的内容提供搜索结果。Agent 无法知道它的结果中现在选择性地遗漏了最后六次提交的更改。搜索结果依然自信地返回。这种“自信”正是问题所在。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="agent-在陈旧视图下会做什么">Agent 在陈旧视图下会做什么<a href="https://tianpan.co/zh/blog/2026-06-03-the-codebase-index-your-coding-agent-rebuilt-from-a-checkout-three-weeks-behind-main#agent-%E5%9C%A8%E9%99%88%E6%97%A7%E8%A7%86%E5%9B%BE%E4%B8%8B%E4%BC%9A%E5%81%9A%E4%BB%80%E4%B9%88" class="hash-link" aria-label="Agent 在陈旧视图下会做什么的直接链接" title="Agent 在陈旧视图下会做什么的直接链接" translate="no">​</a></h2>
<p>编程 Agent 使用索引做两件事：搜索（“帮我找到处理身份验证的函数”）和 Grounding（知识对齐，“这是我要调用的函数签名”）。两者都以不同的方式受到陈旧性的威胁。</p>
<p>搜索功能的退化是悄无声息的。陈旧的索引会返回“过去”实现某个功能的某个文件，Agent 会将其视为真理。如果该文件已被拆分、重命名或合并到另一个模块中，那么从第一次搜索开始，Agent 关于逻辑所在位置的心智模型就是错误的。后续的搜索无法修复这一点——它们通常会变本加厉，因为 Agent 的后续查询是基于第一次搜索返回的内容。</p>
<p>Grounding 的失败有时会表现得更明显。如果 Agent 调用了一个不再存在的函数，编译器通常会捕获它——故事本该到此结束。但现代编程 Agent 的循环是“迭代直到测试通过”，而该循环的许多实现都在构建失败时提供了一个后备方案：删减调用、模拟（mock）返回值、注释掉报错的代码块。Agent 的提示词告诉它要让测试通过，于是它通过移除报错的部分来让测试通过。这个临时方案就这样被发布了。评审者看到 CI 运行通过且 diff 干净，便予以批准。</p>
<p>最阴险的情况是“部分重叠”。Agent 调用的函数确实存在，但其签名与索引记忆的不同。参数顺序改变了，增加了一个必填参数，或者返回类型被缩小了。代码在某些情况下可以编译，但在运行时会崩溃。Agent 永远不会收到明确的信号表明其 Grounding 是错误的，因为这种错误是间歇性的。</p>
<!-- -->
<div class="loading_VaNF">加载中…</div>]]></content>
        <author>
            <name>Tian Pan</name>
            <uri>https://tianpan.co</uri>
        </author>
        <category label="insider" term="insider"/>
        <category label="ai-engineering" term="ai-engineering"/>
        <category label="coding-agents" term="coding-agents"/>
        <category label="infrastructure" term="infrastructure"/>
        <category label="code-review" term="code-review"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[在网关层交换了两个用户上下文的 conversation_id 冲突]]></title>
        <id>https://tianpan.co/zh/blog/2026-06-03-the-conversation-id-collision-that-swapped-two-users-contexts-at-the-gateway</id>
        <link href="https://tianpan.co/zh/blog/2026-06-03-the-conversation-id-collision-that-swapped-two-users-contexts-at-the-gateway"/>
        <updated>2026-06-03T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[扁平的 conversation_id 命名空间加上各层漂移的 UUID 生成器，可能会在网关层交换两个用户的上下文。请像支付团队对待交易 ID 一样严谨地对待会话 ID。]]></summary>
        <content type="html"><![CDATA[<p>收到一张读起来像幻觉一样的客户支持工单。用户附上了一张截图：一个他们从未问过的问题，顶部显示着他们的账户名，接着是引用了他们从未上传过的文件的模型回复。追踪记录看起来很干净。模型完全按照要求执行了任务。问题在于，这个提问完全来自另一个租户，而你的网关由于 <code>conversation_id</code> 值发生了碰撞，将两个对话路由到了同一个后端状态。</p>
<p><img decoding="async" loading="lazy" src="https://opengraph-image.blockeden.xyz/api/og-tianpan-co?title=%E5%9C%A8%E7%BD%91%E5%85%B3%E5%B1%82%E4%BA%A4%E6%8D%A2%E4%BA%86%E4%B8%A4%E4%B8%AA%E7%94%A8%E6%88%B7%E4%B8%8A%E4%B8%8B%E6%96%87%E7%9A%84%20conversation_id%20%E5%86%B2%E7%AA%81" alt="" class="img_ev3q"></p>
<p>你在餐巾纸上算了算。UUID v4 有 122 位的熵。在 5000 万个对话的语料库中，发生任何碰撞的生日边界概率远低于五千万分之一。一年前设计系统时你跑过这个计算。数学是正确的。数学现在依然正确。改变的是你的两个后端层不再以相同的方式生成 ID，而数学所描述的概率从来就不是你实际运行中的概率。</p>
<p>这是 ID 方案隐藏得最深的一种失效模式：每个层级单独来看都是正确的，但全局系统是错误的，而且这种差距在某个用户看到另一个用户的数据之前是不可见的。修复方法不是换一个更好的生成器，而是换一种思维方式来思考 ID 契约应该存在于何处。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="你计算的概率是针对你以前拥有的系统">你计算的概率是针对你以前拥有的系统<a href="https://tianpan.co/zh/blog/2026-06-03-the-conversation-id-collision-that-swapped-two-users-contexts-at-the-gateway#%E4%BD%A0%E8%AE%A1%E7%AE%97%E7%9A%84%E6%A6%82%E7%8E%87%E6%98%AF%E9%92%88%E5%AF%B9%E4%BD%A0%E4%BB%A5%E5%89%8D%E6%8B%A5%E6%9C%89%E7%9A%84%E7%B3%BB%E7%BB%9F" class="hash-link" aria-label="你计算的概率是针对你以前拥有的系统的直接链接" title="你计算的概率是针对你以前拥有的系统的直接链接" translate="no">​</a></h2>
<p>生日边界假设有一个从单一分布中提取的单一生成器。一旦你有了两个后端层——每一层都有自己的生成器、自己的随机源、自己版本的 UUID 库——你就有了两个分布，而联合碰撞域不再由你针对单一层运行的数学所描述。</p>
<p>生产环境中出现的偏移模式几乎总是枯燥乏味的。一个层将其 UUID 库从调用 <code>getrandom(2)</code> 的版本升级到了在负载下系统调用返回 <code>EAGAIN</code> 时延迟回退到用户空间 PRNG 的版本。另一个层运行在容器中，其基础镜像在启动时为 CSPRNG 植入了一次种子，并由于熵池比应用程序启动更晚才变热，导致每一个 fork 的 worker 都继承了这个种子。第三个层通过缓存一个请求范围的 UUID 生成器来“优化”性能，结果发现该生成器在应该从 122 位空间提取时，却从 32 位空间提取。</p>
<p>这些更改中的每一个都在本地经过了评审，通过了各自的测试，并在所属的层上平稳上线。但没有一个针对联合分布进行了评审。当你发现五千万分之一变成了五万分之一的那天，就是网关将两个活跃对话路由到同一个后端记录的那天。</p>
<p>教训不是“使用更好的 RNG”。而是 ID 方案的碰撞特性是所有在该命名空间中生成 ID 的生成器组合而成的涌现特性，而这种组合并不是任何单一层可以审计的属性。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="网关是观察这种组合的地方">网关是观察这种组合的地方<a href="https://tianpan.co/zh/blog/2026-06-03-the-conversation-id-collision-that-swapped-two-users-contexts-at-the-gateway#%E7%BD%91%E5%85%B3%E6%98%AF%E8%A7%82%E5%AF%9F%E8%BF%99%E7%A7%8D%E7%BB%84%E5%90%88%E7%9A%84%E5%9C%B0%E6%96%B9" class="hash-link" aria-label="网关是观察这种组合的地方的直接链接" title="网关是观察这种组合的地方的直接链接" translate="no">​</a></h2>
<p>你的网关是路径中唯一一个在 ID 通往后端状态的途中能看到来自每一层的组件。它通常也是对这些 ID 进行验证最少的组件。默认模式是对 <code>conversation_id</code> 进行哈希以选择后端 Pod，路由请求，并让后端根据其存储解析 ID。如果两个层在不同的后端记录中生成了相同的 ID，网关会将请求路由到哈希选中的那个 Pod 上的记录。另一个记录就变得不可见了。对话被服务给了一个不该看到的其他用户。</p>
<p>将网关视为命名空间契约的执行点。解析为两个不同后端记录的 <code>conversation_id</code> 是一个路由错误，而不是后端错误，因为网关才拥有全局视图。捕捉到这一点的验证并不复杂。在每个请求中，检查该 ID 是否存在于多个后端存储中；如果是，则拒绝路由并触发告警。当碰撞罕见时（这是你预期的运行状态），这种检查成本很低；当碰撞变得频繁时（这是你需要捕捉的状态），警报会非常响亮。</p>
<p>这种做法会给每个请求增加一跳的反对意见是客观存在的，答案是你可以进行采样。在稳定的流量形态下，1% 的采样可以在一分钟内发现五万分之一的碰撞率，这正是你将“用户正在读取其他用户的数据”从耗时数小时的支持升级转化为 P 级事故所需的响应速度。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="按租户划分的前缀可以改变爆炸半径">按租户划分的前缀可以改变爆炸半径<a href="https://tianpan.co/zh/blog/2026-06-03-the-conversation-id-collision-that-swapped-two-users-contexts-at-the-gateway#%E6%8C%89%E7%A7%9F%E6%88%B7%E5%88%92%E5%88%86%E7%9A%84%E5%89%8D%E7%BC%80%E5%8F%AF%E4%BB%A5%E6%94%B9%E5%8F%98%E7%88%86%E7%82%B8%E5%8D%8A%E5%BE%84" class="hash-link" aria-label="按租户划分的前缀可以改变爆炸半径的直接链接" title="按租户划分的前缀可以改变爆炸半径的直接链接" translate="no">​</a></h2>
<p>即使你的生成器是完美的，碰撞的爆炸半径也是由命名空间结构决定的，而不是由任何单个 ID 的熵决定的。扁平的 <code>conversation_id</code> 命名空间意味着任何碰撞都有可能是跨租户的；而 <code>tenant_id:conversation_id</code> 命名空间则意味着碰撞只能发生在租户内部，这会将安全事件转化为（虽然仍然糟糕，但可控的）一致性错误。</p>
<p>架构上的转变是将租户隔离视为 ID 结构的属性，而不是消费 ID 的应用程序逻辑的属性。如果你的 ID 在前缀中带有租户信息，那么“这个 ID 是否可能属于另一个租户”的问题在命名空间级别就有了唯一答案，处理它们的应用程序代码在编写时就无需时刻担心租户检查是一种自律而非保证。每一个处理 ID 的地方都可能遗漏租户检查；将租户作为 ID 的结构属性可以消除大部分此类风险。</p>
<p>这种做法会将租户标识符泄露到 URL 和日志中的反对意见也是客观存在的，答案是对于 Agent 产品来说，租户边界已经是每项操作中最重要的事实。如果你无法从请求中辨别它属于哪个租户，你就无法审计、无法限流、无法计费，也无法调查跨租户事件。无论如何，租户信息都会出现在你的日志中。将其放入 ID 是确保其在 ID 中保持一致性的成本最低的方法。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="单一分配机构使组合变得合理">单一分配机构使组合变得合理<a href="https://tianpan.co/zh/blog/2026-06-03-the-conversation-id-collision-that-swapped-two-users-contexts-at-the-gateway#%E5%8D%95%E4%B8%80%E5%88%86%E9%85%8D%E6%9C%BA%E6%9E%84%E4%BD%BF%E7%BB%84%E5%90%88%E5%8F%98%E5%BE%97%E5%90%88%E7%90%86" class="hash-link" aria-label="单一分配机构使组合变得合理的直接链接" title="单一分配机构使组合变得合理的直接链接" translate="no">​</a></h2>
<p>深层次修复多层漂移问题的方法是停止让每个层级在共享命名空间中生成 ID。运行一个拥有该命名空间并暴露生成 API 的单一 ID 分配服务。各层级调用它。各层级不再拥有 UUID 库。它们拥有一个 HTTP 客户端，向分配器请求给定租户的下一个 ID，获取字符串并使用它。分配器的 RNG（随机数生成器）是系统中唯一的 RNG。分配器的库版本是系统中唯一的库版本。分配器的审计是你唯一需要的审计。</p>
<p>这种做法的成本是在创建对话时增加了一次网络跳转，而这几乎从不在热路径上。其收益在于，你的冲突特性现在是一个易于理解的单一组件的属性，而不是每个层级局部选择的涌现属性。当你将分配器的生成器从 UUID v4 升级到 UUID v7 时，你只需通过一次评审，就能在所有地方同时完成升级。当你发现某个生成器表现不佳时，你只有一个地方需要调查，也只有一个地方需要修复。</p>
<p>这与你的支付系统已经使用的模式相同。支付团队中没有人会在结账服务内部生成交易 ID。存在一个交易分配机构，它有自己的冗余和审计，每个需要交易 ID 的服务都会向它请求。支付系统之所以这样工作，是因为两个各自独立的正确生成器所产生的跨交易故障模式是不可接受的，而且在罕见的创建路径上增加一次网络跳转的成本，与审计漏洞的成本相比微不足道。智能体产品（Agentic products）具有相同的跨对话故障模式，但尚未采用同样的纪律。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="对话-id-除了名称之外本质上就是支付-id">对话 ID 除了名称之外，本质上就是支付 ID<a href="https://tianpan.co/zh/blog/2026-06-03-the-conversation-id-collision-that-swapped-two-users-contexts-at-the-gateway#%E5%AF%B9%E8%AF%9D-id-%E9%99%A4%E4%BA%86%E5%90%8D%E7%A7%B0%E4%B9%8B%E5%A4%96%E6%9C%AC%E8%B4%A8%E4%B8%8A%E5%B0%B1%E6%98%AF%E6%94%AF%E4%BB%98-id" class="hash-link" aria-label="对话 ID 除了名称之外，本质上就是支付 ID的直接链接" title="对话 ID 除了名称之外，本质上就是支付 ID的直接链接" translate="no">​</a></h2>
<p>将这一切联系起来的论据很简短。在智能体产品中，<code>conversation_id</code> 是用户与模型关系中起承重作用的主键。它索引用户的记忆。它索引用户的计费。它索引审计追踪。它是跨租户隔离所依赖的关键。系统中每一个有意义的操作要么将 <code>conversation_id</code> 作为输入，要么将其作为输出。围绕它的纪律至少应该等同于围绕交易 ID 的纪律。</p>
<p>大多数团队存在的错位在于，对话 ID 的标识符方案是在早期，由一名工程师在第一个冲刺阶段决定的，当时只有一个后端，ID 如何跨层级组合的问题还不是一个问题。三年后，有了四个层级，最初的工程师已经去了不同的团队，而当初设计的 ID 方案对于现在的产品来说已经是承重结构了。由于没有人安排补课，严谨性从未跟上。</p>
<p>理想状态的具体表现：</p>
<ul>
<li class="">单一分配器拥有对话命名空间，并且是生产环境中唯一能向其中生成 ID 的组件。</li>
<li class="">每个 ID 都带有租户前缀，即使随机后缀发生冲突，也能限制跨租户的波及范围。</li>
<li class="">网关对传入的 ID 进行采样或完整验证，确保其仅对应一条后端记录，并在出现重复时发出报警。</li>
<li class="">ID 方案的安全性评审频率与支付方案一致，明确询问“如果任何单一组件发生漂移，跨租户的故障模式是什么”。</li>
<li class="">拥有分配器的团队有一套针对“生成器似乎退化”的运行手册，并且该手册已经过演练。</li>
</ul>
<p>这些都不罕见。其中三条是完全从支付工程中借鉴过来的。它们没有在智能体技术栈中传播的原因是，“conversation_id”听起来像是聊天记录的一个细节，而不是一个主键，而那些将其视为细节的团队，正是那些最终会编写事故报告的团队。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="纪律转移就是核心工作">纪律转移就是核心工作<a href="https://tianpan.co/zh/blog/2026-06-03-the-conversation-id-collision-that-swapped-two-users-contexts-at-the-gateway#%E7%BA%AA%E5%BE%8B%E8%BD%AC%E7%A7%BB%E5%B0%B1%E6%98%AF%E6%A0%B8%E5%BF%83%E5%B7%A5%E4%BD%9C" class="hash-link" aria-label="纪律转移就是核心工作的直接链接" title="纪律转移就是核心工作的直接链接" translate="no">​</a></h2>
<p>将 <code>conversation_id</code> 视为它实际所在的主键。停止让每个层级在共享命名空间中生成。让网关验证组合情况。将租户放入 ID 中，使隔离成为一种结构性属性，而不是一种依赖开发者纪律的属性。执行支付团队已经执行了三十年的安全评审。</p>
<p>关于 UUID 冲突的数学计算是正确的。但它对于一个你并未运行的系统是正确的。你所运行的系统是多个生成器的组合，其冲突特性是该组合的属性，而这个组合才是你应该审计的对象。当你像支付团队对待交易那样严谨地对待对话命名空间之日，就是这类事故从你的周报“调查中”栏目消失之时。</p>]]></content>
        <author>
            <name>Tian Pan</name>
            <uri>https://tianpan.co</uri>
        </author>
        <category label="multi-tenant" term="multi-tenant"/>
        <category label="api-gateway" term="api-gateway"/>
        <category label="identifiers" term="identifiers"/>
        <category label="agent-infra" term="agent-infra"/>
        <category label="reliability" term="reliability"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[你的 Agent 每一轮都在重新生成对话摘要，只因缓存键包含了一个时间戳]]></title>
        <id>https://tianpan.co/zh/blog/2026-06-03-the-conversation-summary-your-agent-regenerated-each-turn-because-the-cache-key-included-a-timestamp</id>
        <link href="https://tianpan.co/zh/blog/2026-06-03-the-conversation-summary-your-agent-regenerated-each-turn-because-the-cache-key-included-a-timestamp"/>
        <updated>2026-06-03T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[一行为了调试而做的代码变更，在摘要缓存键中加入了一个时间戳，导致 LLM 账单在两周内悄无声息地翻了三倍 —— 本文探讨了为什么缓存键是一项契约而非简单的工程细节，以及为什么缓存命中率必须作为核心观测指标。]]></summary>
        <content type="html"><![CDATA[<p><img decoding="async" loading="lazy" src="https://opengraph-image.blockeden.xyz/api/og-tianpan-co?title=%E4%BD%A0%E7%9A%84%20Agent%20%E6%AF%8F%E4%B8%80%E8%BD%AE%E9%83%BD%E5%9C%A8%E9%87%8D%E6%96%B0%E7%94%9F%E6%88%90%E5%AF%B9%E8%AF%9D%E6%91%98%E8%A6%81%EF%BC%8C%E5%8F%AA%E5%9B%A0%E7%BC%93%E5%AD%98%E9%94%AE%E5%8C%85%E5%90%AB%E4%BA%86%E4%B8%80%E4%B8%AA%E6%97%B6%E9%97%B4%E6%88%B3" alt="" class="img_ev3q"></p>
<p>一个只被写入却从未被读取的缓存算不上缓存。它只是一个增加了额外延迟、按 KB 计费的日志系统。而这种失效模式最残酷的版本是，从每个角度看缓存都是健康的：<code>set</code> 调用成功，<code>get</code> 调用返回迅速，键（key）格式正确，值（value）有效，TTL 设置合理。唯一的问题是，没有任何一次 <code>get</code> 调用能找到之前 <code>set</code> 调用写入的键，因为键中的一个字段在每次计算时都会发生变化。</p>
<p>这是一个关于调试过程的故事：为了“能分辨出我正在看的是哪条缓存记录”，一位工程师在缓存键中添加了一个时间戳。结果，在没人察觉的两个星期里，系统悄悄地为每场对话多支付了 14 次额外的 LLM 调用费用。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="为缓存而生的架构">为缓存而生的架构<a href="https://tianpan.co/zh/blog/2026-06-03-the-conversation-summary-your-agent-regenerated-each-turn-because-the-cache-key-included-a-timestamp#%E4%B8%BA%E7%BC%93%E5%AD%98%E8%80%8C%E7%94%9F%E7%9A%84%E6%9E%B6%E6%9E%84" class="hash-link" aria-label="为缓存而生的架构的直接链接" title="为缓存而生的架构的直接链接" translate="no">​</a></h2>
<p>该 Agent 处理的是长期运行的客户支持对话。随着用户逐渐发现该产品可以跨多轮对话保留上下文，平均对话长度在一年中稳步攀升。团队最终频繁触及模型的上下文窗口上限，因此需要一套策略。他们选择了最显而易见的一种：滚动摘要——随着对话增长重新生成摘要，并将其置于新一轮对话的开头，以取代旧消息。</p>
<p>摘要本身是由另一次单独的模型调用生成的。使用更便宜的模型、简洁的提示词和结构化输出。单次生成的成本只有几美分，听起来不算贵，但如果每一轮对话都要支付这笔费用，乘以对话总量，成本将变得极其高昂。因此，团队采取了正确的做法：将其缓存。</p>
<p>缓存键非常直接：</p>
<p><code>hash(conversation_id, last_message_id)</code></p>
<p>这种逻辑正是你想要的。产生相同摘要输入的两轮对话会生成相同的键。“对话 47，消息 12 之后”的摘要只计算一次，并在随后的每次读取中重复使用，直到第 13 条消息到来。此时键发生变化，计算新的摘要。命中率在数月内保持在 94% 左右，这大致相当于“读取现有摘要的轮次”与“生成新摘要的轮次”之比，与数学预测基本相符。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="增加了一个字段的调试会话">增加了一个字段的调试会话<a href="https://tianpan.co/zh/blog/2026-06-03-the-conversation-summary-your-agent-regenerated-each-turn-because-the-cache-key-included-a-timestamp#%E5%A2%9E%E5%8A%A0%E4%BA%86%E4%B8%80%E4%B8%AA%E5%AD%97%E6%AE%B5%E7%9A%84%E8%B0%83%E8%AF%95%E4%BC%9A%E8%AF%9D" class="hash-link" aria-label="增加了一个字段的调试会话的直接链接" title="增加了一个字段的调试会话的直接链接" translate="no">​</a></h2>
<p>一名初级工程师正在调查一个不相关的 Bug，即摘要偶尔看起来过时。实际原因是上游消息存储中的竞态条件（race condition），但在调查期间，该工程师想要一种快速的方法在调试器中区分缓存条目。于是，他们在缓存键中添加了一个 <code>cached_at</code> 时间戳。</p>
<p>在当时的背景下，他们的想法是合理的：“我一直在看两条记录，却分不清哪条是我刚刚写的。”时间戳意味着每次写入都会产生一个明显不同的键，他们可以通过时间戳后缀将缓存内容与日志关联起来。PR 完全实现了描述的功能。审查者看到缓存层的一个小改动，只增加了一行代码，由于缓存除了“往返测试”外没有其他测试，因此不需要更改测试，于是批准了它。</p>
<p>竞态条件 Bug 最终在别处被修复了。时间戳字段却被遗忘了。缓存层继续作为直写（write-through）存储运行：每次调用都写入一个新条目，返回新计算的值，然后继续。从外部看，没有任何异常。摘要端点返回了正确的结果。延迟略高，但完全在噪声范围内。没有抛出任何错误。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="没人察觉的那两周">没人察觉的那两周<a href="https://tianpan.co/zh/blog/2026-06-03-the-conversation-summary-your-agent-regenerated-each-turn-because-the-cache-key-included-a-timestamp#%E6%B2%A1%E4%BA%BA%E5%AF%9F%E8%A7%89%E7%9A%84%E9%82%A3%E4%B8%A4%E5%91%A8" class="hash-link" aria-label="没人察觉的那两周的直接链接" title="没人察觉的那两周的直接链接" translate="no">​</a></h2>
<p>更改发布的当天，缓存命中率从 94% 降至 0%。在接下来的两周里，摘要生成的 LLM 账单翻了三倍。团队在月度财务审查中注意到了成本飙升，但他们的第一反应是从产品层面而非系统层面解读：“对话变长了，更多对话超过了压缩阈值，所以我们生成了更多摘要。”这是一个合乎逻辑的叙述，符合前六个月的趋势线。可惜它是错的。</p>
<p>真正的诊断结果是在一名工程师对一个 15 轮的对话进行了端到端的性能分析，并发现相对于 1 次的基准竟然生成了 14 次摘要时才浮出水面的。在旧的键方案下，第 3 到第 14 轮的摘要会被缓存并复用；而在新方案下，每一轮都会产生一个之前从未写入过的键，因此缓存对每次读取实际上都是失效的（cold）。</p>
<p>有几件事导致误诊持续了这么久：</p>
<ul>
<li class="">缓存层指标页面显示了“每秒写入”和“每秒读取”，但没有“命中率”，因为在构建缓存时没人把命中率接入到仪表盘。团队一直假设缓存是工作的，因为应用程序在正常运行。</li>
<li class="">LLM 供应商的计费仪表盘是按模型而不是按调用位置汇总的。增长表现为“对摘要模型的调用增多”，这虽然属实，但没有任何信息量。</li>
<li class="">对话长度的分布确实在变长。这产生了一个真实但微弱的次要信号，证实了团队的第一个假设，这意味着缓存退化隐藏在了一个真实的趋势之中。</li>
<li class="">摘要端点没有能捕获延迟漂移的 SLO。每次单独调用的延迟都在预算内。总成本是症状，而成本仪表盘每月才审查一次。</li>
</ul>
<p>最终的修复只删掉了一行代码。而排查过程耗费了两名工程师一整天的时间。</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token comment" style="color:#999988;font-style:italic"># 修复前</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">cache_key </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token builtin">hash</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">conversation_id</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> last_message_id</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> timestamp</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">now</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token comment" style="color:#999988;font-style:italic"># 修复后</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">cache_key </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token builtin">hash</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">conversation_id</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> last_message_id</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token comment" style="color:#999988;font-style:italic">## 缓存键是契约，而非管道</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">这里的错误并非笔误。它是一个关于缓存键用途的类别错误。团队一直将缓存键视为管道（plumbing）：一个为了方便而生成的、只要唯一就行的不透明标识符。增加一个字段，没关系。移除一个字段，也没关系。它只是一个字符串。</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">但缓存键并不是管道。它是缓存与系统其他部分之间达成的契约，规定了哪些调用是等效的。键中的每一个字段都是一种形式的声明：“在这两个字段上有所不同的调用是不同的调用，我不应该重复使用结果。”一个在每次调用时都会变化的字段则是在声明：“每次调用都是不同的调用”，这等同于在说“我不应该是一个缓存”。</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">当你编写键时，你就是在编写策略。该策略具有成本影响、正确性影响和延迟影响，它理应像任何其他策略一样接受审查。由于链条中的每个人都将其视为管道，那个单行增加的时间戳才通过了代码审查。</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">一个有用的经验法则：如果你无法回答“如果我在键中添加这个字段，预期的命中率是多少？”这个问题，说明你对这项改动的了解还不足以发布它。对于“如果我添加一个每次调用都不同的时间戳，命中率会怎样”的回答是“它会降至零”，在任何审查中这都应该是刺眼的警示灯。</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token comment" style="color:#999988;font-style:italic">## 写与读之间的不对称性</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">这种失败模式之所以如此顽固，部分原因在于直写式缓存（write</span><span class="token operator" style="color:#393A34">-</span><span class="token plain">through caches）在读取路径上是静默降级的，而在成本路径上则是显性降级的。写入路径一直在正常工作。存储的值是正确的。TTL 有效。键也存在。如果你检查缓存，你会看到条目。如果你衡量存储，你会看到增长。所有表示“缓存存活”的指标都在继续显示“缓存存活”。</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">缺失的是缓存本应提供的读取侧放大效应。一个健康的缓存会将 N 次昂贵的调用转化为 </span><span class="token number" style="color:#36acaa">1</span><span class="token plain"> 次昂贵的调用和 N</span><span class="token operator" style="color:#393A34">-</span><span class="token number" style="color:#36acaa">1</span><span class="token plain"> 次廉价的调用。而一个失效的缓存会将 N 次昂贵的调用转化为 N 次昂贵的调用，加上你现在还要支付的 N 次廉价写入费用。成本仪表盘会比延迟仪表盘更早地显示出第二种模式，因为 LLM 调用的延迟波动非常大，以至于在现有预算内增加一次调用通常看起来就像是噪音。</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">这就是为什么“缓存命中率”需要成为一等信号的底层原因，它必须独立于“缓存健康状况”和“缓存延迟”。缓存可以同时做到 </span><span class="token number" style="color:#36acaa">100</span><span class="token operator" style="color:#393A34">%</span><span class="token plain"> 健康和 </span><span class="token number" style="color:#36acaa">0</span><span class="token operator" style="color:#393A34">%</span><span class="token plain"> 有用，只有命中率指标能区分这两种状态。信号不需要很复杂。按端点划分的命中和未命中的计数器，相除，并在滚动窗口内跌幅超过阈值时触发报警，这样就能在第一天发现这种回归（regression）。</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token comment" style="color:#999988;font-style:italic">## 如何弥合差距</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">可以预防或快速发现此类回归的具体实践：</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token operator" style="color:#393A34">-</span><span class="token plain"> 缓存键 Lint 规则：标记任何值取决于墙上时钟时间（wall</span><span class="token operator" style="color:#393A34">-</span><span class="token plain">clock time）、请求时间或单调计数器（monotonic counter）的字段。这些字段的存在有其合理原因（如版本控制、作废令牌），但应要求在调用处进行显式注释，以便迫使审查者确认其意图。</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token operator" style="color:#393A34">-</span><span class="token plain"> 每个端点的命中率仪表盘：设置“在滚动 </span><span class="token number" style="color:#36acaa">24</span><span class="token plain"> 小时内命中率下降超过 </span><span class="token number" style="color:#36acaa">10</span><span class="token plain"> 个百分点”的警报阈值。这种警报的部署成本很低。而与之对应的负面空间——“我有一个没在衡量的缓存”——才是真正昂贵的代价。</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token operator" style="color:#393A34">-</span><span class="token plain"> 代码审查清单：任何涉及缓存键构造的更改，都要求作者在 PR 描述中陈述更改前后的预期命中率。写下数字的行为迫使作者去预测效果。审查者阅读预测的行为创造了一个可证伪的声明，合并后可以对照生产数据进行检查。</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token operator" style="color:#393A34">-</span><span class="token plain"> 模拟负载测试：演练一个典型的对话并断言昂贵的下游调用次数。对于一个总结长对话的 Agent 而言，断言是“一个 </span><span class="token number" style="color:#36acaa">15</span><span class="token plain"> 轮的对话在超过压缩阈值后，最多应产生一次总结生成”。一旦缓存键发生偏移，此类测试就会立即失败。</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token operator" style="color:#393A34">-</span><span class="token plain"> 定期消融实验（ablation experiment）：定期临时移除缓存键中的每个字段并测量命中率。移除后命中率不变的字段要么是死字段，要么是冗余的。增加后未命中率不变的字段对正确性没有贡献。这两者都是值得调查的信号。</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">这些都不罕见。它们是你应用在任何其他成本敏感型基础设施上的相同纪律。它们在缓存中经常缺失的原因是，缓存通常在有流量之前就被构建好了，由那些尚未见过故障的工程师完成；而当流量出现时，缓存正在“工作”，没有人想去动它。</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token comment" style="color:#999988;font-style:italic">## 架构层面的教训</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">这个故事背后隐藏的更深层论点是：一个只被写入而从未被读取的缓存不是缓存，一个包含每次调用时间戳的键也不是键。它们是抽象的退化案例，而抽象在退化时并不会报错。系统在把自己报告为正确系统的同时，愉快地变成了错误的系统。</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">这在 LLM 基础设施中尤为常见，因为单次错误的成本会被单次模型调用的成本放大。HTTP 缓存中的一次未命中会耗费你 </span><span class="token number" style="color:#36acaa">100</span><span class="token plain"> 毫秒和几个 CPU 周期。而总结缓存中的一次未命中会耗费你几美分、数秒的延迟以及一小部分上下文配额——而且你是在每一次本该命中的调用中都要承担这些。LLM 工作负载中缓存的杠杆作用，恰恰是让静默回归变成昂贵账单的杠杆；将缓存视为一等可观测表面的纪律，是使用这一抽象必须付出的代价。</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">不监控缓存命中率的团队构建了一个系统，在那里，一行代码的调试改动可能比开发一个功能还要昂贵。而监控它的团队构建了一个系统，在那里，这行代码的改动在合并当天的早晨会表现为图表下跌，并在午饭前被回滚。这其中的区别不在于架构的复杂性，而在于是否愿意将缓存视为可能静默失败的事物，并接入那个让失败变得可见的唯一指标。</span><br></div></code></pre></div></div>]]></content>
        <author>
            <name>Tian Pan</name>
            <uri>https://tianpan.co</uri>
        </author>
        <category label="insider" term="insider"/>
        <category label="llm-infrastructure" term="llm-infrastructure"/>
        <category label="caching" term="caching"/>
        <category label="observability" term="observability"/>
        <category label="cost-optimization" term="cost-optimization"/>
        <category label="context-engineering" term="context-engineering"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[你的财务团队构建的那个排除了 Embedding 重新索引成本的成本仪表盘]]></title>
        <id>https://tianpan.co/zh/blog/2026-06-03-the-cost-dashboard-that-excluded-the-embeddings-reindex</id>
        <link href="https://tianpan.co/zh/blog/2026-06-03-the-cost-dashboard-that-excluded-the-embeddings-reindex"/>
        <updated>2026-06-03T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[按功能划分的仪表盘跟踪 Token 消耗。按供应商划分的仪表盘跟踪发票。而每季度的 Embedding 重新索引成本则介于两者之间，最终落入无人认领的基础设施桶中 —— 在那里，40% 的 AI 支出在未经审查的情况下悄然流失。]]></summary>
        <content type="html"><![CDATA[<p>你的财务团队构建了一个精美的 AI 成本仪表盘。Token 支出，按功能划分。Embedding 支出，按供应商划分。每个季度，按功能的面板都会在领导会议上接受审查，有人会问为什么支持聊天（support-chat）的工作流增长了 12%，而产品经理会给出一个合理的解释。每个季度，按供应商的面板都会在基础设施会议上接受审查，有人会问为什么 OpenAI 的支出增长了 8%，而平台工程师会给出一个合理的解释。然而，每个季度，真正让你 AI 账单翻倍的那一行——语料库重索引（corpus re-index）——却落入了一个名为“基础设施”的第三个篮子里，没有人审查它，因为没有人负责。</p>
<p><img decoding="async" loading="lazy" src="https://opengraph-image.blockeden.xyz/api/og-tianpan-co?title=%E4%BD%A0%E7%9A%84%E8%B4%A2%E5%8A%A1%E5%9B%A2%E9%98%9F%E6%9E%84%E5%BB%BA%E7%9A%84%E9%82%A3%E4%B8%AA%E6%8E%92%E9%99%A4%E4%BA%86%20Embedding%20%E9%87%8D%E6%96%B0%E7%B4%A2%E5%BC%95%E6%88%90%E6%9C%AC%E7%9A%84%E6%88%90%E6%9C%AC%E4%BB%AA%E8%A1%A8%E7%9B%98" alt="" class="img_ev3q"></p>
<p>那个篮子是 40% 的 AI 支出在没有归属的情况下白白流失的地方。本可以优化它的团队从未见过它。而能看到它的团队却无法告诉你它是为哪个功能服务的。仪表盘对它能解释的所有成本都保持诚实，而对它无法解释的成本保持沉默，而这恰恰是至关重要的成本。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="仪表盘构建的两个维度">仪表盘构建的两个维度<a href="https://tianpan.co/zh/blog/2026-06-03-the-cost-dashboard-that-excluded-the-embeddings-reindex#%E4%BB%AA%E8%A1%A8%E7%9B%98%E6%9E%84%E5%BB%BA%E7%9A%84%E4%B8%A4%E4%B8%AA%E7%BB%B4%E5%BA%A6" class="hash-link" aria-label="仪表盘构建的两个维度的直接链接" title="仪表盘构建的两个维度的直接链接" translate="no">​</a></h2>
<p>按功能归属成本作为主视图是有道理的，因为它回答了高管们实际会问的问题：“这个支持副驾驶（support copilot）花了我们多少钱？”实现这一目标的架构举措很小——在每个 LLM 调用上打一个 <code>feature_id</code> 标签，通过网关传播，并每晚进行汇总。在 2026 年撰写关于 LLM FinOps 的从业者们不断回到这个决定，因为这是决定按功能核算是否可能的唯一一行代码。</p>
<p>按供应商归属作为第二个维度也是合理的，因为采购团队负责供应商关系，并需要根据实际数字进行谈判。OpenAI、Anthropic、Voyage 的费用都在这里汇总。基础设施团队可以比较单价，与客户代表沟通，并决定何时进行迁移。</p>
<p>这两种视角都是正确的。两者都很有用。但两者都看不到季度的重索引支出。</p>
<p>原因是结构性的：重索引（re-index）不是一个请求（request）。它不会带着 <code>feature_id</code> 请求头通过你的网关。它不会出现在你的单次调用遥测数据中。它作为一个批处理作业运行——由平台工程师发起，通常在非工作时间，针对一个不属于任何单一产品团队的语料库——并在每月一号作为供应商发票上的一个巨大的分列项目计费。你的按功能仪表盘看不到它，因为没有任何东西标记它。你的按供应商仪表盘能看到它，但无法说明它的用途。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="为什么重索引才是真正花钱的地方">为什么重索引才是真正花钱的地方<a href="https://tianpan.co/zh/blog/2026-06-03-the-cost-dashboard-that-excluded-the-embeddings-reindex#%E4%B8%BA%E4%BB%80%E4%B9%88%E9%87%8D%E7%B4%A2%E5%BC%95%E6%89%8D%E6%98%AF%E7%9C%9F%E6%AD%A3%E8%8A%B1%E9%92%B1%E7%9A%84%E5%9C%B0%E6%96%B9" class="hash-link" aria-label="为什么重索引才是真正花钱的地方的直接链接" title="为什么重索引才是真正花钱的地方的直接链接" translate="no">​</a></h2>
<p>数字因技术栈而异，但模式是高度一致的。使用前沿 Embedding 模型对一亿个 Token 的语料库进行重新 Embedding，仅 API 费用就在 5,000 到 15,000 美元之间，这还不包括读取、分块（chunking）和写回向量数据库所消耗的计算资源。因为语料库会发生偏移，每季度执行一次；每当你升级 Embedding 模型时，再做一次；每当你更改分块策略时，再做一次。于是你就有了一项经常性开支，它超过了所有小功能整个月的 Token 支出总和。</p>
<p>实际支付过这些账单的团队报告了同样的模式：基础设施成本的增长速度超过了单次调用成本，因为单次调用成本是可见的且得到了优化，而批处理成本是不可见的且无人问津。撰写关于内部存储系统隐藏成本的工程师指出，Embedding API 的价格微不足道——每百万 Token 仅几分钱——但运行重索引流水线的运营税、向量数据库的出网流量、存储周转、以及验证所需的工程师人周，使这些成本比 API 账单高出一个数量级。OpenAI 账单上的那个标题数字其实是实际成本中最小的一部分。</p>
<p>当你询问运行重索引的团队它发生的频率时，你会得到类似“每个季度左右”的答案。当你问为什么是每个季度时，你会得到类似“因为模型版本更新了”或“因为索引质量下降了”或“因为我们想尝试更小的 Embedding 维度”之类的回答。这些答案没有一个与产品路线图挂钩。重索引的节奏是由 Embedding 模型的发布时间表和发现召回率下降的平台工程师的耐心决定的，而这两者都不是预算负责人。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="名为基础设施的篮子是所有权隐藏的地方">名为“基础设施”的篮子是所有权隐藏的地方<a href="https://tianpan.co/zh/blog/2026-06-03-the-cost-dashboard-that-excluded-the-embeddings-reindex#%E5%90%8D%E4%B8%BA%E5%9F%BA%E7%A1%80%E8%AE%BE%E6%96%BD%E7%9A%84%E7%AF%AE%E5%AD%90%E6%98%AF%E6%89%80%E6%9C%89%E6%9D%83%E9%9A%90%E8%97%8F%E7%9A%84%E5%9C%B0%E6%96%B9" class="hash-link" aria-label="名为“基础设施”的篮子是所有权隐藏的地方的直接链接" title="名为“基础设施”的篮子是所有权隐藏的地方的直接链接" translate="no">​</a></h2>
<p>云成本报告中有一个从业者们疲惫地描述的反复出现的模式：被归属的成本是有人有理由去归属的成本。按功能支出被归属，是因为产品经理需要合理的预算。按供应商支出被归属，是因为采购在谈判中需要筹码。其他一切都落在残余的篮子里——称之为“基础设施”、“平台”或“共享”——它们在构造上就是无人负责的。这个篮子之所以存在，是因为如果不这样，就得承认你的 AI 支出中有很大一部分没有负责人，这在董事会报告中会显得很尴尬。</p>
<!-- -->
<div class="loading_VaNF">加载中…</div>]]></content>
        <author>
            <name>Tian Pan</name>
            <uri>https://tianpan.co</uri>
        </author>
        <category label="insider" term="insider"/>
        <category label="finops" term="finops"/>
        <category label="embeddings" term="embeddings"/>
        <category label="rag" term="rag"/>
        <category label="cost-attribution" term="cost-attribution"/>
        <category label="leadership" term="leadership"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[服务商在 API 边界遵守但在缓存处违反的数据驻留契约]]></title>
        <id>https://tianpan.co/zh/blog/2026-06-03-the-data-residency-contract-your-provider-honored-at-the-api-boundary-and-broke-at-the-cache</id>
        <link href="https://tianpan.co/zh/blog/2026-06-03-the-data-residency-contract-your-provider-honored-at-the-api-boundary-and-broke-at-the-cache"/>
        <updated>2026-06-03T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[区域 API 终端节点承诺了你的请求去向，但未承诺满足该请求的缓存前缀字节存放地。可审计边界与缓存部署边界受不同的 SLA 约束 —— 而这一差距正是合规态势失效之处。]]></summary>
        <content type="html"><![CDATA[<p>你的驻留审计追踪了来自租户流量的每一个出站请求，观察它在法兰克福的一个主机名上终止，并签了字。审计对它所测量的一切都是正确的。但它也看错了层级。请求确实去了欧盟。但满足该请求的字节——即服务商哈希并从最近可用节点提取的缓存前缀——却存放在 us-east-1。你的区域端点向你承诺了一个目的地。而缓存什么也没承诺，因为缓存是一个不同的产品，受不同的 SLA 约束，是为成本而非合规而设计的。</p>
<p><img decoding="async" loading="lazy" src="https://opengraph-image.blockeden.xyz/api/og-tianpan-co?title=%E6%9C%8D%E5%8A%A1%E5%95%86%E5%9C%A8%20API%20%E8%BE%B9%E7%95%8C%E9%81%B5%E5%AE%88%E4%BD%86%E5%9C%A8%E7%BC%93%E5%AD%98%E5%A4%84%E8%BF%9D%E5%8F%8D%E7%9A%84%E6%95%B0%E6%8D%AE%E9%A9%BB%E7%95%99%E5%A5%91%E7%BA%A6" alt="" class="img_ev3q"></p>
<p>客户的审计员发现了它。不是你的。另一个供应商的事件报告提到 Prompt 缓存的放置与推理区域是解耦的，于是客户的 GRC 团队提出了那个显而易见的后续问题：我们的前缀去了哪里？修补这一差距的合同修正案花了 90 天。续约被暂停了。根据他们拿到的文档，编写集成的团队并没有做错任何事。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="两个边界并不是同一个边界">两个边界并不是同一个边界<a href="https://tianpan.co/zh/blog/2026-06-03-the-data-residency-contract-your-provider-honored-at-the-api-boundary-and-broke-at-the-cache#%E4%B8%A4%E4%B8%AA%E8%BE%B9%E7%95%8C%E5%B9%B6%E4%B8%8D%E6%98%AF%E5%90%8C%E4%B8%80%E4%B8%AA%E8%BE%B9%E7%95%8C" class="hash-link" aria-label="两个边界并不是同一个边界的直接链接" title="两个边界并��不是同一个边界的直接链接" translate="no">​</a></h2>
<p>区域 API 端点是一种路由承诺。你将客户端指向 <code>eu-frankfurt.provider.com</code>，请求在区域内的基础设施上终止。这就是你的网络级审计可以查看并认证的边界。出站数据包、目标 IP、TLS 握手——全部可观察、可审计、且清白。</p>
<p>Prompt 缓存是另一回事。它是一个内容寻址存储，通过前缀的哈希值（系统提示词、工具架构、少样本示例）进行索引，以便来自任何租户的相同前缀都可以重用同一个条目。缓存的全部意义在于避免重复计算服务商已经为某些人计算过的注意力状态。这种经济逻辑只有在缓存规模庞大、共享并放置在计算能力附近（而非请求来源附近）时才有效。镜像你路由拓扑的按区域缓存会破坏这种节省模型。</p>
<p>因此，服务商将缓存构建为一个全球池。前缀被哈希，哈希值被写入任何具有淘汰空间的节点，而该节点是由不知道你合同存在的容量启发式算法选择的。你的欧盟租户请求在法兰克福终止。缓存写入则去了 us-east。审计看的是正门。而字节却从后窗溜走了。</p>
<p>这不是疏忽。这两个系统是由不同的团队针对不同的需求设计的。推理层附带区域承诺，因为客户有此要求。缓存层附带成本承诺，因为每个 Token 的经济效益需要如此。这两个团队对于各自的 SLA 都没有错。错的是客户，他们认为这两个 SLA 是可以叠加组合的。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="为什么资产清单会遗漏它">为什么资产清单会遗漏它<a href="https://tianpan.co/zh/blog/2026-06-03-the-data-residency-contract-your-provider-honored-at-the-api-boundary-and-broke-at-the-cache#%E4%B8%BA%E4%BB%80%E4%B9%88%E8%B5%84%E4%BA%A7%E6%B8%85%E5%8D%95%E4%BC%9A%E9%81%97%E6%BC%8F%E5%AE%83" class="hash-link" aria-label="为什么资产清单会遗漏它的直接链接" title="为什么资产清单会遗漏它的直接链接" translate="no">​</a></h2>
<p>合规团队的驻留控制目录中有关于推理的条目。但可能没有关于缓存的条目。原因很平凡：在构建数据清单时，缓存要么是服务商尚未公开的私有优化，要么是一个没有按区域承诺的资产功能。清单捕获了可清单化的内容。服务商后来添加的、作为免费优化销售并默认启用的任何功能都位于目录之外。</p>
<p>这种模式在各服务商中重复出现。仔细阅读驻留承诺，你会发现如下条款：“在不支持区域处理的区域中，扩展 Prompt 缓存可能需要我们在区域外处理并临时存储客户内容，以提供服务。”这句话完整地描述了泄露。但它也埋在帮助中心的文章里，而不是 GRC 团队在采购时审查的主协议中。客户签署的合同谈论的是推理。提到缓存位置的脚注则在更深的两层点击之后。</p>
<p>另一种失败模式同样常见。客户通过超大规模云厂商介导的路径进行路由——如 Bedrock 法兰克福、Vertex EU——认为云厂商的区域承诺涵盖了模型提供商的承诺。对于推理调用，通常确实如此。但对于辅助功能，情况并非总是这样。Bedrock 关于 Prompt 缓存的文档包含免责声明：“缓存是区域性的，因此你可能会向同一个推理配置文件发送两个完全相同的请求，如果它们被路由到不同的区域，第二个请求可能会出现缓存未命中”——这是从另一个方向描述的相同事实。如果服务商确实遵守区域缓存，你将付出缓存未命中率的代价。如果服务商不遵守，你将付出驻留差距的代价。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="驻留图表实际涵盖的内容">驻留图表实际涵盖的内容<a href="https://tianpan.co/zh/blog/2026-06-03-the-data-residency-contract-your-provider-honored-at-the-api-boundary-and-broke-at-the-cache#%E9%A9%BB%E7%95%99%E5%9B%BE%E8%A1%A8%E5%AE%9E%E9%99%85%E6%B6%B5%E7%9B%96%E7%9A%84%E5%86%85%E5%AE%B9" class="hash-link" aria-label="驻留图表实际涵盖的内容的直接链接" title="驻留图表实际涵盖的内容的直接链接" translate="no">​</a></h2>
<p>画一张你会向审计员展示的驻留图表。一个标记为“客户端”的方框，一个指向标记为“欧盟端点”的方框的箭头，一个指向标记为“推理集群 (eu-frankfurt)”的方框的箭头。图表闭合了；字节留在区域内。审计员签字。</p>
<p>现在画一张实现图。同样的客户端，同样的欧盟端点，同样的推理集群。但在集群之前，有一个标记为“缓存查询”的步骤。在响应发出之前，有一个标记为“缓存写入”的步骤。这两个步骤都与一个标记为“全球 Prompt 缓存”的服务通信，该服务的分片位于驻留图表未列出的区域中。其中一些分片保存着你的租户发出的每一个请求的前缀。如果服务商缓存输出，其中一些分片还会保存响应。</p>
<p>第一张图是你的合同涵盖的。第二张图是你的数据实际流经的。你拥有的合规姿态是第一张图的函数。你实际需要的合规姿态则是第二张图的函数。两者之间的差距就是缓存层，从你这一侧进行再精细的路由也无法弥合它。</p>
<p>关于这一差距已有研究文献。引入 MemPool（一个跨推理实例管理分布式 KV 缓存的弹性内存池）的 arXiv 论文描述了一个全球调度器，通过基于全球 Prompt 树的局部性感知策略来增强缓存重用。调度器优化的局部性是“前缀局部性”，而非“租户局部性”。另一篇关于通过共享 KV 缓存泄露 Prompt 的 NDSS 论文指出，在接受调查的 8 家大模型服务商中，有 7 家在全球范围内跨用户共享缓存。两篇论文都是关于系统级效率的。它们也都无意中描述了一种驻留风险。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="弥补差距的模式">弥补差距的模式<a href="https://tianpan.co/zh/blog/2026-06-03-the-data-residency-contract-your-provider-honored-at-the-api-boundary-and-broke-at-the-cache#%E5%BC%A5%E8%A1%A5%E5%B7%AE%E8%B7%9D%E7%9A%84%E6%A8%A1%E5%BC%8F" class="hash-link" aria-label="弥补差距的模式的直接链接" title="弥补差距的模式的直接链接" translate="no">​</a></h2>
<p>仅靠消费端的架构技巧无法解决这个问题。每一条解决路径要么需要与供应商变更合同，要么需要刻意牺牲缓存所提供的优化。选择一个你能承受的成本方案。</p>
<p><strong>修改合同以明确缓存的数据驻留（Residency）。</strong> 规定 <em>推理</em> 驻留的合同并不等同于规定 <em>缓存</em> 驻留。解决方法是增加一个明确针对每个区域的条款，并要求供应商证明缓存层的数据驻留是独立于推理层进行审计的。主要供应商的企业级合同可以包含此类措辞；这通常不是默认提供的，采购部门必须主动提出。典型的成本是 90 天的修订周期。在你的续约窗口中预留出这段缓冲时间。</p>
<p><strong>针对受监管流量选择禁用共享缓存。</strong> 每个主流供应商都允许你按请求禁用 Prompt 缓存，通常是通过 Header 或参数实现的。对于欧盟租户的流量禁用它，是用 Token 节省换取数据驻留保证。这取决于缓存命中率；如果你的前缀（Prefixes）稳定且经常复用，那么成本确实存在。如果你的前缀本就随请求变化，那么成本几乎可以忽略不计。在假设它太贵之前，先计算一下。</p>
<p><strong>像审计推理层一样审计缓存层。</strong> 仅追踪出站请求目的地的驻留审查是在审查错误的层级。将审查扩展到追踪一个样本前缀，从请求到缓存写入，以验证目的地。供应商并不总是会提供你所需的自省（Introspection）能力。去争取它。你无法看到缓存层这一事实本身就是一个审计发现。</p>
<p><strong>将每一项“免费优化”都视为合同约定的范畴。</strong> Prompt 缓存只是一个例子。模型路由、请求批处理、子处理器变更、默认内容安全过滤——所有这些都是供应商为了优化成本或质量而默认开启的功能，其中任何一项都可能导致字节跨越合同规定的边界。采购时的架构评审应列举这些功能，并根据数据驻留清单对每一项进行验证。那些不在清单上的，就是你需要询问的对象。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="架构层面的认知">架构层面的认知<a href="https://tianpan.co/zh/blog/2026-06-03-the-data-residency-contract-your-provider-honored-at-the-api-boundary-and-broke-at-the-cache#%E6%9E%B6%E6%9E%84%E5%B1%82%E9%9D%A2%E7%9A%84%E8%AE%A4%E7%9F%A5" class="hash-link" aria-label="架构层面的认知的直接链接" title="架构层面的认知的直接链接" translate="no">​</a></h2>
<p>供应商的区域端点（Regional Endpoint）是对请求去向的承诺。但这并不是对满足你请求的字节存储位置的承诺。这是关于不同层级的不同陈述，如果团队将前者解读为对后者的保证，那么其建立的合规态势就依赖于一个供应商从未披露过的拓扑结构。</p>
<p>这不仅限于 Prompt 缓存。同样的情况也出现在非 AI 工作负载的 CDN 边缘缓存、特征存储（Feature Stores）的跨区域复制、以及基于队列的消息传递中（队列的区域部署独立于生产者和消费者）。在每种情况下，可审计边界（请求路由）和不可审计边界（下游存储位置）都受制于同一供应商的不同 SLA，而客户的合规态势仅取决于两者中最薄弱的一环。</p>
<p>防御性的姿态是假设缓存拓扑是未披露的，在签署合同时明确询问，并将每一项默认开启的优化视为潜在的越界行为，直到供应商书面承诺该特定功能的数据驻留。而进攻性的姿态是围绕列举跨边界接触面（Surface）而非列举端点来设计采购评审。这两种姿态最终会得到相同的清单；但只有第二种姿态能捕捉到供应商在合同签署后推出的下一个功能。</p>
<p>将数据驻留合同视为路由保证的团队，构建的是一个依赖于供应商披露其行为的控制面。而将其视为逐层承诺的团队，构建的是一个依赖于供应商同意他们“不会做什么”的控制面。第二种姿态才能在下一次产品发布中生存下来。</p>]]></content>
        <author>
            <name>Tian Pan</name>
            <uri>https://tianpan.co</uri>
        </author>
        <category label="ai-engineering" term="ai-engineering"/>
        <category label="data-residency" term="data-residency"/>
        <category label="compliance" term="compliance"/>
        <category label="prompt-caching" term="prompt-caching"/>
        <category label="gdpr" term="gdpr"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[那个将你的系统提示词泄露到客户审计日志中的调试日志器]]></title>
        <id>https://tianpan.co/zh/blog/2026-06-03-the-debug-logger-that-put-your-system-prompt-in-a-customer-readable-audit-feed</id>
        <link href="https://tianpan.co/zh/blog/2026-06-03-the-debug-logger-that-put-your-system-prompt-in-a-customer-readable-audit-feed"/>
        <updated>2026-06-03T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[你的平台团队为了排查故障而添加的一个字段，最终出现在了租户的审计导出中 —— 这次泄露不需要任何攻击者，仅仅是两个“正确”的决定组合在了一起。]]></summary>
        <content type="html"><![CDATA[<p>一位具有安全意识的客户拉取了其租户的审计导出文件，打开 JSON，从一个名为 <code>llm.request.system</code> 的字段中读到了逐字记录的拒绝策略、检索流水线结构以及一些内部产品标识符。没有漏洞利用。没有提示词注入。没有越狱。仅仅是你的平台团队在六个月前添加的一个日志字段，目的是让工程师能够将提示词版本与事件关联起来——结果通过你的企业团队出于 SOC 2 合规原因单独向租户开放的摘要（feed）泄露了出来。</p>
<p><img decoding="async" loading="lazy" src="https://opengraph-image.blockeden.xyz/api/og-tianpan-co?title=%E9%82%A3%E4%B8%AA%E5%B0%86%E4%BD%A0%E7%9A%84%E7%B3%BB%E7%BB%9F%E6%8F%90%E7%A4%BA%E8%AF%8D%E6%B3%84%E9%9C%B2%E5%88%B0%E5%AE%A2%E6%88%B7%E5%AE%A1%E8%AE%A1%E6%97%A5%E5%BF%97%E4%B8%AD%E7%9A%84%E8%B0%83%E8%AF%95%E6%97%A5%E5%BF%97%E5%99%A8" alt="" class="img_ev3q"></p>
<p>泄露发生在周三下午。你的安全团队是由客户呼叫（paged）的，而不是报警系统。事件时间线显示泄露当天并没有部署——错误配置是在审计摘要扩大其字段白名单的那天发布的，那是另一个团队、另一个迭代周期（sprint）和另一张工单。两名评审员都批准了他们所看到的内容。但没有人从全局组合的角度去审视。</p>
<p>提示词提取研究一直将其视为对抗性问题，但实际上这正日益演变成一个配置问题。最近的研究显示，在企业级 AI 评估中，系统提示词提取的成功率约为 60%，而已公布的缓解方案仍被定义为“针对用户对模型进行加固”。但在事后分析（postmortems）中显示的泄露并非源于措辞巧妙的越狱。它们源于系统提示词被写入了一个你的访问控制模型视为普通元数据的地方。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="无人负责的裂缝">无人负责的裂缝<a href="https://tianpan.co/zh/blog/2026-06-03-the-debug-logger-that-put-your-system-prompt-in-a-customer-readable-audit-feed#%E6%97%A0%E4%BA%BA%E8%B4%9F%E8%B4%A3%E7%9A%84%E8%A3%82%E7%BC%9D" class="hash-link" aria-label="无人负责的裂缝的直接链接" title="无人负责的裂缝的直接链接" translate="no">​</a></h2>
<p>一个调试字段的生命周期至少有三个交接环节，在孤立地看时似乎都没有争议。</p>
<p>平台团队将 <code>llm.request.system</code> 添加到结构化日志中，以支持一个真实的工程需求：将运行中的提示词版本与事件关联起来。这是一个合理的决定。如果没有它，在故障期间你无法回答“哪个版本的系统提示词产生了此答案”，也无法有把握地废弃一个糟糕的提示词。</p>
<p>可观测性团队将该字段摄取到追踪存储（trace store）中，方式与摄取其他每个字段相同——按类型。它是一个字符串。字符串有一个配置好的脱敏器（redactor），用于擦除电子邮件地址、信用卡模式以及任何符合 PII 正则表达式的内容。脱敏器无法识别系统提示词，因为系统提示词看起来不像 PII。它看起来像产品文案。</p>
<p>企业团队根据不同的路线图，扩展了客户可见的审计摘要，以展示请求级别的字段，以便租户能够满足他们自己的审计人员。白名单是由了解客户需求的业务经理（PM）构建的。他们加入了 <code>llm.request.system</code>，因为运行多模型评估的租户希望看到他们的查询命中哪个提示词。PM 将其视为调试字段。客户的审计员将其视为记录在案的供应商行为。从各自的角度来看，两者都是正确的。</p>
<p>这三名评审员都没有全局视角。平台团队认为日志是工程师可见的。可观测性团队认为敏感性意味着 PII。企业团队认为摘要的范围仅限于租户。没有人明确谁该负责“系统提示词在所有这些层面中是否敏感？”这个问题，因为这个问题跨越了每个团队的边界。</p>
<p>OWASP LLM Top 10 中关于系统提示词泄露的内容明确告诉你要将系统提示词视为潜在公开的，且绝不要将其作为安全控制手段。对于提示词所包含的内容，这是个好建议。但在涉及到提示词本身是否构成泄露的问题时，这个建议并无帮助。拒绝策略之所以敏感，并不是因为它隐藏了凭据。它之所以敏感，是因为它属于产品表面——即你的助手如何拒绝、拥有哪些工具、会避开哪些话题，以及你的检索流水线认为哪些内容是可检索的编码形式。这是具有竞争力的资料。有时它还涉及监管要求。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="为什么脱敏器漏掉了它">为什么脱敏器漏掉了它<a href="https://tianpan.co/zh/blog/2026-06-03-the-debug-logger-that-put-your-system-prompt-in-a-customer-readable-audit-feed#%E4%B8%BA%E4%BB%80%E4%B9%88%E8%84%B1%E6%95%8F%E5%99%A8%E6%BC%8F%E6%8E%89%E4%BA%86%E5%AE%83" class="hash-link" aria-label="为什么脱敏器漏掉了它的直接链接" title="为什么脱敏器漏掉了它的直接链接" translate="no">​</a></h2>
<p>PII 脱敏器是基于“敏感数据具有可识别的表面形式”这一假设进行训练的。电话号码有特定形状。电子邮件有特定形状。甚至 API 密钥和 JWT 都有可识别的前缀和熵分布。基于正则和机器学习的脱敏器之所以能达到一定的精度，是因为它们利用了这些结构化先验。</p>
<p>系统提示词的结构化先验是“用你产品语气的指令”。它与文档、营销常见问题解答或支持宏（support macro）无法区分。脱敏器看到的是看起来像内容的东西，然后就放行了。来自 OpenTelemetry 社区的安全可观测性研究一直在推动分层、具有驻留意识（residency-aware）的分类，正是因为正则时代无法解决这个问题——敏感性是字段来源（provenance）的属性，而不是字段形状的属性。</p>
<p>这就是为什么字段类型的敏感性标签是目前最接近真实修复的机制。创建 <code>llm.request.system</code> 的平台团队应该被要求在定义字段时附加分类，而不是在写日志时。分类应该是 <code>confidential-product</code>（机密产品）或 <code>model-attribution-only</code>（仅限模型归因）或者任何你的分类体系——但缺失标签应该默认拒绝，而不是默认允许。</p>
<p>Datadog 和类似的日志平台已经发布了限制访问日志路径的 API，但这种限制是选择性开启（opt-in）的。添加字段的团队必须记住该字段是敏感的，并配置限制。记忆并不是一种安全控制。将未知字段默认为最宽松的级别，正是产生裂缝的原因。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="组合规则">组合规则<a href="https://tianpan.co/zh/blog/2026-06-03-the-debug-logger-that-put-your-system-prompt-in-a-customer-readable-audit-feed#%E7%BB%84%E5%90%88%E8%A7%84%E5%88%99" class="hash-link" aria-label="组合规则的直接链接" title="组合规则的直接链接" translate="no">​</a></h2>
<p>一个更深刻的教训是：两个关于可见性的独立且正确的决策，组合起来后可能演变成一次信息泄露，而你现有的评审流程并非为了捕获这种“组合”而设计的。</p>
<p>扩展审计流（audit feed）白名单的变更请求通常只是一个小工单。评审者会问：这些字段分享给租户安全吗？他们孤立地观察每个字段，看到它是一个字符串，看到该字段已经应用了 PII 脱敏，看到数据分类工具没有发出告警，于是予以批准。而最初负责 <code>llm.request.system</code> 变更的评审者会问：这个字段在内部记录日志安全吗？他们确认了其中不包含用户 PII，确认了工程师在处理故障排查时需要它，于是也予以批准。</p>
<p>这两位评审者都不是针对“组合结果”的评审者。在你的组织架构中，没有也不应该有“审计流 × LLM 字段”的评审员——你无法为功能的每一个笛卡尔积都增加一名评审员。这种机制必须依靠字段自身的分类，并让分类随字段跨系统流动。要像对待类型签名一样对待敏感度标签：一个接收来自另一系统字符串的函数，除非类型告知，否则它无法理解该字符串的含义，而“string”并不是一个有用的类型。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="当提示词成为产品界面时会发生什么变化">当提示词成为产品界面时，会发生什么变化<a href="https://tianpan.co/zh/blog/2026-06-03-the-debug-logger-that-put-your-system-prompt-in-a-customer-readable-audit-feed#%E5%BD%93%E6%8F%90%E7%A4%BA%E8%AF%8D%E6%88%90%E4%B8%BA%E4%BA%A7%E5%93%81%E7%95%8C%E9%9D%A2%E6%97%B6%E4%BC%9A%E5%8F%91%E7%94%9F%E4%BB%80%E4%B9%88%E5%8F%98%E5%8C%96" class="hash-link" aria-label="当提示词成为产品界面时，会发生什么变化的直接链接" title="当提示词成为产品界面时，会发生什么变化的直接链接" translate="no">​</a></h2>
<p>如果你接受系统提示词（system prompts）是产品的一部分——即它们像配置文件一样编码了行为——那么一系列决策将会随之改变：</p>
<p>系统提示词将变成受版本控制的产物，拥有与鉴权层（auth layer）相同的评审纪律。它们会有差异对比（diff），会有变更日志条目，会有归属说明。你可能已经在非正式地这样做。将其正式化意味着提示词有一组已知的消费者——你的推理层、评估框架、A/B 测试框架——任何新的消费者都需要经过安全评审，就像为你的会话令牌（session-token）表添加新消费者一样。</p>
<p>引用提示词的日志字段在定义时就需要贴上敏感度标签。如果一个新的字段名称匹配 <code>llm.request.*</code> 或 <code>agent.system.*</code> 模式且没有标签，CI 检查将拦截该 PR。该检查不需要理解字段的语义，它只需要强制执行：让进行变更的工程师做出明确的分类决策。PR 描述会将这一决策带入评审环节。</p>
<p>面向客户的审计流和面向工程师的链路追踪存储（trace store）拥有分别维护的白名单，并且两者都遵循“默认拒绝”原则。向其中任何一个添加字段都是一种刻意行为。负责审计流的团队拥有关于租户应该看到哪些字段的文档化流程，且该流程明确列出“任何 LLM 请求或响应字段”都需要进行分类评审。</p>
<p>此前专注于推理时提示词提取（prompt extraction）的红队演练，现在也要针对你面向客户的审计流、日志导出和追踪 UI 进行。红队脚本非常简单——在任何租户可访问的导出内容中查找具有提示词特征的字符串——它能捕获那些绕过你推理层缓解措施的精确失效模式。如果你现有的渗透测试覆盖范围不包括这一点，增加它的成本仅需一个工程师日。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="事件复盘将重新发现的架构教训">事件复盘将重新发现的架构教训<a href="https://tianpan.co/zh/blog/2026-06-03-the-debug-logger-that-put-your-system-prompt-in-a-customer-readable-audit-feed#%E4%BA%8B%E4%BB%B6%E5%A4%8D%E7%9B%98%E5%B0%86%E9%87%8D%E6%96%B0%E5%8F%91%E7%8E%B0%E7%9A%84%E6%9E%B6%E6%9E%84%E6%95%99%E8%AE%AD" class="hash-link" aria-label="事件复盘将重新发现的架构教训的直接链接" title="事件复盘将重新发现的架构教训的直接链接" translate="no">​</a></h2>
<p>这一类别下的每一份事故后复盘（postmortem）最终都会得出类似结论：泄露过程不需要攻击者。组合后的系统替攻击者完成了本该由他们做的工作。模型从未提取过提示词，因为根本没人问它；是审计流直接把提示词交了出来。</p>
<p>复盘报告中往往倾向于建议缩减审计流白名单并使用更智能的脱敏工具。作为即时补救措施，这两者都是正确的。但它们都没有解决底层属性：即 LLM 应用中接触提示词的表面（surface），要比非 LLM 应用中接触配置文件的表面多得多。每一个可观测性工具、每一个重放框架、每一个评估流水线、每一个 A/B 日志记录器都能看到提示词，因为提示词本身就是让调用变得具有记录价值的原因。其中每一个都是潜在的泄露路径，而每个路径的评审者又各不相同。</p>
<p>架构层面的举措是将分类推向字段的起点，并强制其随之流动。发送 <code>llm.request.system</code> 的记录器应该调用一个需要敏感度标签的 API，如果缺少标签则拒绝发送。追踪存储应该拒绝为超过特定模式阈值的未打标字段建立索引。审计流应该拒绝展示超过特定敏感度等级的字段，除非针对工单记录了明确的租户导出批准。这些都不是新颖的机制。这是你处理会话令牌、支付数据和 PII 的既有方式。转变在于：意识到提示词也属于此类范畴。</p>
<p>提示词提取不是因为攻击者变聪明了才产生的“研究课题”。它是一个架构问题，因为你的系统提示词是极具价值的产品资产，目前却被当作调试字符串对待。你所在行业的下一次事故将是以下两者之一：客户在审计流中发现了该字段并悄悄归档，或者竞争对手从他们付费获取的日志导出中读到了你的拒绝策略。前者你会在支持队列中看到；后者你可能永远都发现不了。在两者发生之前消除这一差距的团队，是那些已经开始像分类令牌一样分类 LLM 字段的团队——根据字段产生的位置分类，而非根据字段看起来的样子。</p>]]></content>
        <author>
            <name>Tian Pan</name>
            <uri>https://tianpan.co</uri>
        </author>
        <category label="llm-security" term="llm-security"/>
        <category label="observability" term="observability"/>
        <category label="audit-logs" term="audit-logs"/>
        <category label="prompt-leakage" term="prompt-leakage"/>
        <category label="ai-engineering" term="ai-engineering"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[那个在你待办事项中悄悄更改的弃用日期]]></title>
        <id>https://tianpan.co/zh/blog/2026-06-03-the-deprecation-date-that-moved-while-it-sat-in-your-backlog</id>
        <link href="https://tianpan.co/zh/blog/2026-06-03-the-deprecation-date-that-moved-while-it-sat-in-your-backlog"/>
        <updated>2026-06-03T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[供应商可以在不发布差异对比、不发送通知的情况下直接修改弃用日期。当初将原始日期放入延期池的团队，直到收到支持工单时才发现大事不妙。]]></summary>
        <content type="html"><![CDATA[<p>弃用通知在某个周二送达，停用日期定在六个月后。你的平台团队将其记录在依赖跟踪器中，贴上 “Q3 切换” 标签，并标记为黄色严重度。它与队列中已有的另外两个迁移任务汇合。三周后，供应商在同一个 URL 下修改了日期，没有 diff，没有收件箱通知，只有一段悄悄更新的文字，将停用日期提前了 60 天，直接挪到了你的代码冻结期中间。</p>
<p><img decoding="async" loading="lazy" src="https://opengraph-image.blockeden.xyz/api/og-tianpan-co?title=%E9%82%A3%E4%B8%AA%E5%9C%A8%E4%BD%A0%E5%BE%85%E5%8A%9E%E4%BA%8B%E9%A1%B9%E4%B8%AD%E6%82%84%E6%82%84%E6%9B%B4%E6%94%B9%E7%9A%84%E5%BC%83%E7%94%A8%E6%97%A5%E6%9C%9F" alt="" class="img_ev3q"></p>
<p>你视为规划文档的生命周期页面，其实一直是一个合同闹钟。唯一改变的是它控制着哪个团队的日历——而拥有这个日历的团队并不是你的。</p>
<p>这种模式在各供应商之间惊人地一致。你最高价值业务负载所依赖的模型出现了一个带有未来日期的弃用横幅。你将其记录在案。你根据其他工作进行优先级排序。你按照原始日期所暗示的严肃程度对待它。然后，日期变了，而且变动后的版本成了唯一存在的版本，因为页面是唯一的事实来源，而页面内容刚刚被就地重写。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="延期桶是对供应商记忆的赌博">延期桶是对供应商记忆的赌博<a href="https://tianpan.co/zh/blog/2026-06-03-the-deprecation-date-that-moved-while-it-sat-in-your-backlog#%E5%BB%B6%E6%9C%9F%E6%A1%B6%E6%98%AF%E5%AF%B9%E4%BE%9B%E5%BA%94%E5%95%86%E8%AE%B0%E5%BF%86%E7%9A%84%E8%B5%8C%E5%8D%9A" class="hash-link" aria-label="延期桶是对供应商记忆的赌博的直接链接" title="延期桶是对供应商记忆的赌博的直接链接" translate="no">​</a></h2>
<p>每个工程组织都有一个名为 “延期”、“Q3” 或 “迁移后” 的优先级分配桶，这在功能上等同于 “我们相信这在处理它之前不会出问题”。这种桶适用于内部工作，因为截止日期由你的团队控制且可以重新协商。但对于外部截止日期，它就失效了，因为唯一的协商渠道是供应商的客户经理，而他们没有任何动力给你宽限。</p>
<p>模型弃用通知属于另一个不同的范畴。它更接近于监管截止日期，而非功能需求。供应商内部已经承诺了停用——路线图上已经写明，支撑你模型的 GPU 算力将被重新分配；后续模型的采用指标取决于削减前代模型的资源；某个财务项目取决于在特定季度关闭旧架构的推理。发布的日期只是供应商内部已经缩小范围的软期限。将其视为规划提示是一种分类错误。</p>
<p>修复方案是将弃用通知通过团队处理严格外部截止日期的任何管道进行分发。如果你的团队有 SOC 2 审计准备的运行手册，那么弃用通知也应该进入同样的运行手册。切换关口应该被命名为弃用公告发布的那一天，而不是你乐观假设它会保持不变的日期的前一周。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="页面即记录系统且支持就地编辑">页面即记录系统，且支持就地编辑<a href="https://tianpan.co/zh/blog/2026-06-03-the-deprecation-date-that-moved-while-it-sat-in-your-backlog#%E9%A1%B5%E9%9D%A2%E5%8D%B3%E8%AE%B0%E5%BD%95%E7%B3%BB%E7%BB%9F%E4%B8%94%E6%94%AF%E6%8C%81%E5%B0%B1%E5%9C%B0%E7%BC%96%E8%BE%91" class="hash-link" aria-label="页面即记录系统，且支持就地编辑的直接链接" title="页面即记录系统，且支持就地编辑的直接链接" translate="no">​</a></h2>
<p>软件工程师有一种本能的预期，即重要的文档应该是带版本的。但大多数供应商发布的生命周期页面并不符合这种预期。只有一个 URL。该 URL 下的内容是可变的。弃用列表的修改没有公开的变更日志，没有提交历史。在许多情况下，当停用日期更改时，甚至没有邮件通知——弃用初次发布时的邮件被认为是足够的，而后续修正则是无声的。</p>
<p>将生产决策锚定在这样一个架构细节上是令人惊讶的。依赖日期的团队并不拥有该日期所在的页面。拥有该页面的团队将其视为可以为清晰起见而修改的营销类文档。两个团队对于该字段的稳定程度有着截然不同的先验假设，而这些假设之间的鸿沟正是生产事故产生的根源。</p>
<p>缓解措施是机械化的：定期轮询页面，对比内容，并在弃用表中的任何字段发生变化时发出警报。轮询频率应该是天，而不是周——每天一次是合理的，如果业务负载依赖于一个弃用成本极高的模型，每小时一次也是偏执但合理的。对比目标应该是整个表，而不只是你当前使用的模型行，因为行的出现和消失都很重要。当警报触发时，行动不应该是 “在下一次同步会议上讨论”。行动是打开运行手册，根据新日期重新评估切换关口。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="标准集上的评估分数并不代表切换授权">标准集上的评估分数并不代表切换授权<a href="https://tianpan.co/zh/blog/2026-06-03-the-deprecation-date-that-moved-while-it-sat-in-your-backlog#%E6%A0%87%E5%87%86%E9%9B%86%E4%B8%8A%E7%9A%84%E8%AF%84%E4%BC%B0%E5%88%86%E6%95%B0%E5%B9%B6%E4%B8%8D%E4%BB%A3%E8%A1%A8%E5%88%87%E6%8D%A2%E6%8E%88%E6%9D%83" class="hash-link" aria-label="标准集上的评估分数并不代表切换授权的直接链接" title="标准集上的评估分数并不代表切换授权的直接链接" translate="no">​</a></h2>
<p>缩短的截止日期迫使切换计划压缩。原计划两周的 A/B 流量测试崩塌为周末的双写和周一早晨的切换。团队需要绿灯，而产生绿灯的产物是评估套件。评估套件返回了一个高于阈值的数字。切换获得授权。</p>
<p>评估套件测量的是固定分布。它是根据编写标签时（几周或几个月前）存在的流量模式和意图组合构建的。生产流量是一个不断移动的实时分布，而破坏客户信任的部分很少是中位数——而是长尾部分，即那些查询聚集在评估集采样不足的输入空间区域的客户群。</p>
<p>这里的两种失败模式是独立且累积的。评估集是快照，因此它落后于实际分布。评估集也是一种经过策划的表现形式，因此它对标注人员认为乏味、模糊或难以评分的部分代表性不足。一个在评估集上得分接近的模型，可能会在特定客户群上产生三倍的错误率，而这些客户的查询在评估集中采样不足。捕捉到这一信号的不是评估分。信号是按客户群细分的生产错误指标，在切换当天及之后的一周内进行监控。</p>
<p>补救措施是使用来自业务负载服务的每个客户群的分层样本来增强评估集，并向非典型查询的细分群体倾斜。从弃用公告发布之日起，将 1% 的生产流量通过替代模型进行预切换试运行，可以在强制切换前为评估集提供六个月的真实分布反馈。运行这种试运行的团队会在第一周发现长尾回归，而不是在切换后的第六天。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="错误率翻了三倍的客户分群">错误率翻了三倍的客户分群<a href="https://tianpan.co/zh/blog/2026-06-03-the-deprecation-date-that-moved-while-it-sat-in-your-backlog#%E9%94%99%E8%AF%AF%E7%8E%87%E7%BF%BB%E4%BA%86%E4%B8%89%E5%80%8D%E7%9A%84%E5%AE%A2%E6%88%B7%E5%88%86%E7%BE%A4" class="hash-link" aria-label="错误率翻了三倍的客户分群的直接链接" title="错误率翻了三倍的客户分群的直接链接" translate="no">​</a></h2>
<p>有一种特定的失败模式值得被命名，因为它已经发生过不止一次。切换在周一发布。到周二，核心错误指标看起来正常。到周五，依然看起来正常。到了下周三，某个单一客户的支持工单量超过了阈值，并呼叫了支持值班人员，后者将其升级给工程团队，工程团队发现该客户的特定工作流自切换以来，其故障率一直是之前的三倍。</p>
<p>该客户的流量在总流量中占比极小，以至于其三倍的错误率并没有波动聚合指标。而聚合指标正是被监控的对象。仪表盘中虽然存在分群细化数据，但并未包含在告警路径中。从切换到触发告警之间的这六天里，该客户的用户一直在经历糟糕的体验，而团队中却无人知晓。</p>
<p>这里的缓解措施是配置切换仪表盘，使其针对每个分群的回归（per-cohort regressions）触发告警，而不仅仅是针对聚合指标。对于代表重要合同价值的分群，阈值应该更加严格；对于那些流失信号比获取成本更难恢复的分群，阈值则应进一步收紧。模型更换后的第一周是这些告警最应该保持敏感的窗口期，因为那是影响最大的回归浮出水面的时候，而且团队的注意力已经处于动员状态。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="生命周期页面是供应商的权威系统">生命周期页面是供应商的权威系统<a href="https://tianpan.co/zh/blog/2026-06-03-the-deprecation-date-that-moved-while-it-sat-in-your-backlog#%E7%94%9F%E5%91%BD%E5%91%A8%E6%9C%9F%E9%A1%B5%E9%9D%A2%E6%98%AF%E4%BE%9B%E5%BA%94%E5%95%86%E7%9A%84%E6%9D%83%E5%A8%81%E7%B3%BB%E7%BB%9F" class="hash-link" aria-label="生命周期页面是供应商的权威系统的直接链接" title="生命周期页面是供应商的权威系统的直接链接" translate="no">​</a></h2>
<p>从具体事件中抽身出来，你会发现一个令人不安的架构现实。你运行的生产系统依赖于存储在某个权威系统（system of record）中的日期，而这个系统并不归你的团队所有，它归属于一个供应商，其动力是优化他们自己的路线图，且其修改日期的流程并不包括通知你。那些不按计划轮询页面的团队，实际上构建了一个依赖于供应商“记得通知他们”的发布日历。而供应商并不会每次都记得。</p>
<p>这与早期软件时代让团队头疼的每种供应商依赖关系如出一辙——在“小版本升级”中发布破坏性变更的 SaaS API，在未更新允许列表的情况下更改 IP 段的 CDN，以及废弃政策仅在发布说明中寥寥一句话的第三方 SDK。这些领域中成熟的模式同样适用于此：轮询真相源，在每次轮询时进行比对（diff），对每次变更发出告警，并将告警视为重新评估计划的信号，而不是可以忽略的噪音。</p>
<p>内化了这一点的团队不再将废弃页面视为计划文档，而是将其视为由供应商操作的控制平面。控制平面在供应商选择的时间发送信号。团队的工作是在信号实际发送的时间尺度上接收这些信号，而不是在团队的季度规划周期所偏好的时间尺度上。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="在提交废弃通知的当天构建切换闸口">在提交废弃通知的当天构建切换闸口<a href="https://tianpan.co/zh/blog/2026-06-03-the-deprecation-date-that-moved-while-it-sat-in-your-backlog#%E5%9C%A8%E6%8F%90%E4%BA%A4%E5%BA%9F%E5%BC%83%E9%80%9A%E7%9F%A5%E7%9A%84%E5%BD%93%E5%A4%A9%E6%9E%84%E5%BB%BA%E5%88%87%E6%8D%A2%E9%97%B8%E5%8F%A3" class="hash-link" aria-label="在提交废弃通知的当天构建切换闸口的直接链接" title="在提交废弃通知的当天构建切换闸口的直接链接" translate="no">​</a></h2>
<p>杠杆率最高的一项改变是结构性的。一旦你所依赖的模型收到了废弃通知，当天就会发生三件事：创建一个指明切换闸口（cutover gate）的运维手册（runbook）条目；配置一个回退流量预演，将一小部分生产流量路由到替代模型中；并添加一个轮询任务，监视生命周期页面中该行的任何字段变更。这些工作都不需要等待下个 Sprint。这些工作都不会被分选到延期池中。</p>
<p>运维手册条目是在迁移积压工作重组中幸存下来的产物。它指明了具体的评估分群、具体的仪表盘、具体的切换窗口以及具体的回滚流程。它由特定的人员而非团队负责，因为延期池中往往塞满了团队拥有的、但无人负责的工单。轮询任务能捕捉到悄无声息的修正。预演则积累了标准评估集无法产生的长尾信号。</p>
<p>这一切的成本都很低。而不这样做的代价是，一个错误率翻了三倍的客户分群经历了六天糟糕的体验，而核心指标却纹丝不动。供应商的生命周期页面将继续就地更新。供应商将继续在不通知的情况下修改日期。团队唯一能控制的变量是：变更是在发生当天就被记录下来，还是在支持工单量迫使你翻开页面的那天。</p>]]></content>
        <author>
            <name>Tian Pan</name>
            <uri>https://tianpan.co</uri>
        </author>
        <category label="llm-ops" term="llm-ops"/>
        <category label="vendor-risk" term="vendor-risk"/>
        <category label="model-lifecycle" term="model-lifecycle"/>
        <category label="incident-response" term="incident-response"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[当用户取消对话后，下游 API 却仍在继续写入]]></title>
        <id>https://tianpan.co/zh/blog/2026-06-03-the-downstream-api-that-kept-writing-after-the-user-cancelled-the-conversation</id>
        <link href="https://tianpan.co/zh/blog/2026-06-03-the-downstream-api-that-kept-writing-after-the-user-cancelled-the-conversation"/>
        <updated>2026-06-03T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[点击停止按钮可以干净地关闭 LLM 流。但这并不会停止工具已经向第三方开启的 HTTP 请求，而第三方并不知道对话已经结束。本文将解释为什么 AbortSignal 止步于套接字，以及你应该在提交边界构建什么来替代它。]]></summary>
        <content type="html"><![CDATA[<p>用户点击停止。浏览器关闭了 SSE 连接。你的 AI SDK 触发了 <code>onAbort</code>。Agent 运行时检测到信号，停止向模型请求更多 token，并终止其循环。从你的代码库内部来看，这次取消显得非常利索。你所能看到的每个子系统都在执行正确的操作。</p>
<p><img decoding="async" loading="lazy" src="https://opengraph-image.blockeden.xyz/api/og-tianpan-co?title=%E5%BD%93%E7%94%A8%E6%88%B7%E5%8F%96%E6%B6%88%E5%AF%B9%E8%AF%9D%E5%90%8E%EF%BC%8C%E4%B8%8B%E6%B8%B8%20API%20%E5%8D%B4%E4%BB%8D%E5%9C%A8%E7%BB%A7%E7%BB%AD%E5%86%99%E5%85%A5" alt="" class="img_ev3q"></p>
<p>与此同时，两秒钟前，模型发出了一个工具调用（tool call）。运行时分发了它。工具的 <code>execute</code> 函数打开了一个连接到第三方 API 的 TCP 连接并发送了 payload。该 HTTP 请求仍在传输中，第三方的服务器仍在处理它，而第三方完全无法得知它所服务的对话已不存在。写入操作成功提交。用户的心智模型认为他们通过点击停止避开了该操作。下游系统的数据库则记录了完全不同的结果。</p>
<p>这种故障模式存在于进程内取消（in-process cancellation）与远程取消（remote cancellation）之间的鸿沟中。大多数工程师对 <code>AbortController</code> 的理解就像 Go 的 <code>context.Context</code> 一样——认为它是一个单一的令牌，可以扇出到调用图中的每一个 goroutine，并在每一个阻塞操作中同时触发取消通道。这种推理在你的进程内部是正确的。但在跨网络通信时，它基本上是错误的。一个穿越了两个 HTTPS 跳点、一个 L7 负载均衡器和一个供应商队列的取消操作，已经不再是取消操作了。它只是一份“奢望”。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="abortsignal-停止的是你的代码而非依赖的代码">AbortSignal 停止的是你的代码，而非依赖的代码<a href="https://tianpan.co/zh/blog/2026-06-03-the-downstream-api-that-kept-writing-after-the-user-cancelled-the-conversation#abortsignal-%E5%81%9C%E6%AD%A2%E7%9A%84%E6%98%AF%E4%BD%A0%E7%9A%84%E4%BB%A3%E7%A0%81%E8%80%8C%E9%9D%9E%E4%BE%9D%E8%B5%96%E7%9A%84%E4%BB%A3%E7%A0%81" class="hash-link" aria-label="AbortSignal 停止的是你的代码，而非依赖的代码的直接链接" title="AbortSignal 停止的是你的代码，而非依赖的代码的直接链接" translate="no">​</a></h2>
<p><code>AbortController</code> 是一个 Web 平台原语，旨在中断在运行时内部执行的阻塞工作。当你将其接入 <code>fetch</code> 时，你是在要求<em>你的运行时</em>关闭 TCP 套接字并拒绝该 Promise。这就是它的作用。它确实非常有用：提供商侧的 GPU 会在几百毫秒内察觉到套接字关闭并停止生成 token，这就是为什么流式 LLM 取消在推理本身上效果很好的原因。</p>
<p>但是，一旦你的工具 <code>execute</code> 函数向第三方（非 LLM 提供商）发送 HTTP 请求时——无论是 Stripe、Mailgun、Salesforce 还是你自己的内部服务，任何执行副作用的操作——取消协议就改变了。关闭一个写入端点的连接会产生以下三种结果之一，具体取决于服务器的实现，而不是你的：</p>
<ul>
<li class="">服务器在处理程序达到提交点<em>之前</em>检测到套接字已关闭并中止。这是你潜意识里假设的情况。</li>
<li class="">服务器在提交<em>之后</em>检测到套接字已关闭，尝试刷新响应，刷新失败，并记录一条“客户端已断开（client gone）”的日志。而写入操作已经完成。</li>
<li class="">服务器根本没有检测到套接字关闭，因为请求已进入异步处理队列。下游的 worker 在十秒钟后从队列中取出消息，并针对一个早已关闭的对话执行操作。</li>
</ul>
<p>三种不同的结果，只有一种符合用户的预期。运行时无法区分它们，因为信号止步于套接字。第三方没有可以监听的类似 <code>ctx.Done()</code> 的等效机制。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="除非你在设计中加入否则取消令牌不会跨越进程边界">除非你在设计中加入，否则取消令牌不会跨越进程边界<a href="https://tianpan.co/zh/blog/2026-06-03-the-downstream-api-that-kept-writing-after-the-user-cancelled-the-conversation#%E9%99%A4%E9%9D%9E%E4%BD%A0%E5%9C%A8%E8%AE%BE%E8%AE%A1%E4%B8%AD%E5%8A%A0%E5%85%A5%E5%90%A6%E5%88%99%E5%8F%96%E6%B6%88%E4%BB%A4%E7%89%8C%E4%B8%8D%E4%BC%9A%E8%B7%A8%E8%B6%8A%E8%BF%9B%E7%A8%8B%E8%BE%B9%E7%95%8C" class="hash-link" aria-label="除非你在设计中加入，否则取消令牌不会跨越进程边界的直接链接" title="除非你在设计中加入，否则取消令牌不会跨越进程边界的直接链接" translate="no">​</a></h2>
<p>Go 开发者在第一次跨服务边界使用 <code>context.Context</code> 时，往往会深刻体会到这个教训。在进程内部，取消父级 context 会立即关闭每个派生 context 的 <code>Done()</code> 通道，并且每个 select 该通道的 goroutine 都会在微秒内返回。但在跨越服务边界时，context 值消失了——HTTP 或 gRPC 的标准信封中没有哪个字段承载“此请求已被上游客户端取消”的信息。</p>
<p>你可以进行近似处理。你可以传播一个截止时间（deadline）请求头，让下游服务在每个操作时检查。你可以在原始 POST 之后发出一个带外的 <code>DELETE /jobs/{id}</code>。你可以在原始请求中包含一个取消令牌，服务器在每个提交点之前对其进行轮询。所有这些都是显式的协议，你必须在连接的两端进行设计、文档记录和强制执行。</p>
<p>LLM 工具调用框架并没有附带这些协议。工具内部的 <code>fetch</code> 与普通的“发后即忘（fire-and-forget）”式 HTTP 调用无异。AI SDK 的 <code>abortSignal</code> 完全存在于该 fetch 的客户端。当 SDK 将信号传递给工具的 <code>execute</code> 函数时，<em>运行时</em>知道取消了，但目标端并不知道它正在做的工作已被放弃。更糟糕的是，运行时的中止操作可能在请求体已经传输完毕后才触发，导致服务器处于一种正在处理请求，而发起者已经挂断的状态。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="比对话寿命更长的异步工作">比对话寿命更长的异步工作<a href="https://tianpan.co/zh/blog/2026-06-03-the-downstream-api-that-kept-writing-after-the-user-cancelled-the-conversation#%E6%AF%94%E5%AF%B9%E8%AF%9D%E5%AF%BF%E5%91%BD%E6%9B%B4%E9%95%BF%E7%9A%84%E5%BC%82%E6%AD%A5%E5%B7%A5%E4%BD%9C" class="hash-link" aria-label="比对话寿命更长的异步工作的直接链接" title="比对话寿命更长的异步工作的直接链接" translate="no">​</a></h2>
<p>当工具的下游 API 是异步的时，会出现这种故障最严重的变体。工具的 <code>execute</code> 函数实际上并没有执行副作用——它只是将其排入队列。它调用类似于 <code>POST /workflows/run</code> 的接口，并收到带有运行 ID 的 <code>202 Accepted</code>。从运行时的角度来看，工具返回成功。从第三方的角度来看，一个工作流现在已排期执行，可能是在几分钟后，也可能是在另一台机器上。</p>
<p>如果用户在工具返回的那一刻中止，运行时会干净利落地取消。对话关闭。用户会话结束。而第三方的 worker 队列对此一无所知。它按自己的计划领取任务，并针对用户认为已经避开的状态运行。副作用会在<em>用户关闭标签页几分钟后</em>提交。</p>
<!-- -->
<div class="loading_VaNF">加载中…</div>]]></content>
        <author>
            <name>Tian Pan</name>
            <uri>https://tianpan.co</uri>
        </author>
        <category label="insider" term="insider"/>
        <category label="agents" term="agents"/>
        <category label="cancellation" term="cancellation"/>
        <category label="tool-use" term="tool-use"/>
        <category label="reliability" term="reliability"/>
        <category label="idempotency" term="idempotency"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[那场无需部署就让你检索召回率减半的 Embedding 弃用事件]]></title>
        <id>https://tianpan.co/zh/blog/2026-06-03-the-embedding-deprecation-that-halved-your-retrieval-recall-without-a-deploy</id>
        <link href="https://tianpan.co/zh/blog/2026-06-03-the-embedding-deprecation-that-halved-your-retrieval-recall-without-a-deploy"/>
        <updated>2026-06-03T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[一个被弃用的 Embedding 端点如果悄悄地路由到某个 “兼容性” 继任者，可能会在无需部署的情况下让你的检索召回率减半。本文将探讨为什么查询/文档 Embedding 不匹配是 RAG 的隐形杀手，以及如何将端点与其生成的语料库进行锚定。]]></summary>
        <content type="html"><![CDATA[<p>在一个 RAG 系统中，可能上线的代价最高昂的嵌入 (embedding) Bug，是那种你的代码库没有任何变化、检索代码没变、索引没变、查询路径也没变的 Bug。然后在第六周的某个周二，有人注意到答案的质量不如从前了。</p>
<p><img decoding="async" loading="lazy" src="https://opengraph-image.blockeden.xyz/api/og-tianpan-co?title=%E9%82%A3%E5%9C%BA%E6%97%A0%E9%9C%80%E9%83%A8%E7%BD%B2%E5%B0%B1%E8%AE%A9%E4%BD%A0%E6%A3%80%E7%B4%A2%E5%8F%AC%E5%9B%9E%E7%8E%87%E5%87%8F%E5%8D%8A%E7%9A%84%20Embedding%20%E5%BC%83%E7%94%A8%E4%BA%8B%E4%BB%B6" alt="" class="img_ev3q"></p>
<p>服务商为你十二个月前构建索引时所使用的嵌入系列发布了停用公告。平台团队将其归档在了一个拥有一年缓冲期的停用仪表盘中，然后就继续处理其他事情了。停用路径并不是一个生硬的截止——而是一个悄无声息的质量退化：被停用的端点开始路由到一个“兼容性”继任者，它返回相同维度的向量，但语义几何空间却有微妙的不同。查询嵌入开始与你一年前嵌入的语料库发生漂移。在六周的时间里，你的常规评估中的 Recall@10 下降了 47%。团队直到一个无关的质量仪表盘达到阈值时才追溯到原因，迫使一名高级工程师进行根因分析，最终发现问题指向了一个在这一年里没人动过的嵌入端点。</p>
<p>这篇文章探讨的是该事件背后的架构错误：将嵌入端点视为可替代的 URL，而不是将其视为与其生成的语料库绑定的版本化依赖。服务商并不总是通过版本升级来承认行为变更，停用时间线通常是上限而非确定的日期，而你的检索召回率正在被供应商以一种你的评估无法察觉的节奏重新定义。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="为什么-兼容性-是停用通知中最危险的词">为什么 “兼容性” 是停用通知中最危险的词<a href="https://tianpan.co/zh/blog/2026-06-03-the-embedding-deprecation-that-halved-your-retrieval-recall-without-a-deploy#%E4%B8%BA%E4%BB%80%E4%B9%88-%E5%85%BC%E5%AE%B9%E6%80%A7-%E6%98%AF%E5%81%9C%E7%94%A8%E9%80%9A%E7%9F%A5%E4%B8%AD%E6%9C%80%E5%8D%B1%E9%99%A9%E7%9A%84%E8%AF%8D" class="hash-link" aria-label="为什么 “兼容性” 是停用通知中最危险的词的直接链接" title="为什么 “兼容性” 是停用通知中最危险的词的直接链接" translate="no">​</a></h2>
<p>从 <code>embeddings-v1</code> 到 <code>embeddings-v2</code> 的版本升级是显性的。你的客户端代码会改变，你的索引文档会更新，你的工单会堆积，而且肯定会有人运行评估。系统有机会在变更发生的瞬间暴露出回归问题。</p>
<p>而“兼容性”继任者则完全相反。供应商保留了 URL，保留了维度，也保留了响应结构。唯一改变的是从文本到向量的映射函数。相同的输入，略有不同的输出，相同的形状。对于你客户端代码的每一行来说，这次调用看起来与昨天完全一样。</p>
<p>这恰恰是问题所在。余弦相似度和点积只有在比较的双方都处于同一个空间时才有意义。一旦你的查询嵌入来自与文档嵌入不同的模型，让邻域 (neighborhoods) 具有意义的几何结构就会失效。从业者将其描述为“拿苹果和橘子做比较”：数字依然会返回，索引依然会返回十个邻居，但大多数邻居现在都是错误的，而且你的技术栈中没有任何环节能检测到这一点。</p>
<p>这种损害的程度取决于两个模型之间的分歧程度，也就是供应商对继任者进行调优的激进程度。一些“兼容性”继任者被校准为近乎同构；而另一些则不然。你无法选择，而且通常也不会被告知。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="停用是一种行为演变而非一个日期">停用是一种行为演变，而非一个日期<a href="https://tianpan.co/zh/blog/2026-06-03-the-embedding-deprecation-that-halved-your-retrieval-recall-without-a-deploy#%E5%81%9C%E7%94%A8%E6%98%AF%E4%B8%80%E7%A7%8D%E8%A1%8C%E4%B8%BA%E6%BC%94%E5%8F%98%E8%80%8C%E9%9D%9E%E4%B8%80%E4%B8%AA%E6%97%A5%E6%9C%9F" class="hash-link" aria-label="停用是一种行为演变，而非一个日期的直接链接" title="停用是一种行为演变，而非一个日期的直接链接" translate="no">​</a></h2>
<p>大多数团队将停用日期视为截止日期：在 T 时刻，端点停止工作。这种心理模型允许你通过消耗直到 T 的时间来资助迁移。对于硬性切断，这是一个有用的模型。</p>
<p>但对于软性切断，这就是一个错误模型。管理着数百万客户集群的供应商会优先考虑可用性——他们宁愿让你继续收到 200 响应，也不愿用 410 错误中断你的业务。因此，停用路径变成了一条曲线：在发布公告和正式退役之间，端点的行为会被重新定义，以降低底层基础设施的运营成本。返回向量的质量就是可调节的旋钮之一。</p>
<p>供应商倾向于用“功能对等调整”或“基础设施改进”这类语言来描述这些变化。发布说明在技术上是准确的，但作为检索系统变差的信号，它们毫无用处。在停用窗口期内，你与端点之间的实际契约是“我们将返回一个形状相同的向量”——而不是“我们将返回一个来自相同语义分布的向量”。</p>
<p>负责检索的团队需要意识到，公布的停用日期是上限，而且在缓冲期内的契约是形状稳定，而非语义稳定。主流服务商现在以 12 到 18 个月的频率更新模型，甚至有几家为了加速淘汰，只给出了 2 到 4 周的停用窗口。你以为拥有的宽限期比公告暗示的要短，而且行为在窗口期内就已经在发生偏移。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="异步评估盲点">异步评估盲点<a href="https://tianpan.co/zh/blog/2026-06-03-the-embedding-deprecation-that-halved-your-retrieval-recall-without-a-deploy#%E5%BC%82%E6%AD%A5%E8%AF%84%E4%BC%B0%E7%9B%B2%E7%82%B9" class="hash-link" aria-label="异步评估盲点的直接链接" title="异步评估盲点的直接链接" translate="no">​</a></h2>
<p>几乎每个团队都有一个用于基准测试嵌入模型的评估 (eval)。但几乎没有人拥有一个用于基准测试已部署索引的评估。这种区别正是这种失效模式能够保持隐形的原因。</p>
<p>标准的嵌入评估是这样的：获取一组带标签的查询集，用模型 X 嵌入查询，用模型 X 嵌入语料库，运行检索，计算 recall@k。当你升级到模型 Y 时，你用 Y 重新嵌入双方并重新评分。评估能正确地告诉你，当双方都处于 Y 空间时，Y 是否比 X 更适合检索。</p>
<p>但这并不是生产环境中发生的情况。在生产环境中，你的文档嵌入是一年前写入索引的，其成本是你那个季度嵌入整个语料库的费用。重新嵌入语料库是一个大工程。因此，真正匹配生产环境的“评估”应该是：由今天的端点嵌入的查询，对比去年端点嵌入的文档，按今天的标签评分。大多数团队从未运行过这种评估，因为他们从未建立过用“今天的端点”去对比“去年的向量”来嵌入查询的基础设施——他们只有一个端点，并且信任它。</p>
<p>当供应商悄悄将该端点迁移到兼容性继任者时，“今天的端点”就不再是“去年的端点”了——但这仅发生在查询阶段。语料库端在索引中是冻结的。如果你按标准方式运行评估（重新嵌入双方），结果看起来会很好，因为它把双方都放回了相同的空间。而你的生产流量由于只能重新嵌入一侧，质量将会下降。</p>
<p>语料库端嵌入（支付一次，难以重做）与查询端嵌入（按请求支付，自动反映今天的端点）之间的不对称性，正是供应商悄然迁移的隐匿之处。任何不保留这种不对称性的评估都是在测试错误的系统。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="锁定你真正需要的契约的模式">锁定你真正需要的契约的模式<a href="https://tianpan.co/zh/blog/2026-06-03-the-embedding-deprecation-that-halved-your-retrieval-recall-without-a-deploy#%E9%94%81%E5%AE%9A%E4%BD%A0%E7%9C%9F%E6%AD%A3%E9%9C%80%E8%A6%81%E7%9A%84%E5%A5%91%E7%BA%A6%E7%9A%84%E6%A8%A1%E5%BC%8F" class="hash-link" aria-label="锁定你真正需要的契约的模式的直接链接" title="锁定你真正需要的契约的模式的直接链接" translate="no">​</a></h2>
<p>如果你需要的契约是“查询和文档嵌入来自同一个模型”，而提供商提供的契约仅仅是“相同维度的向量”，那么其中的差距就需要由你来弥补。在问题发生之前，有几种模式值得提前建立。</p>
<p><strong>在语料库层面锁定嵌入模型版本。</strong> 你索引中的每个文档都应该有一个挂载字段（sidecar field）：<code>embedding_model_id</code>、<code>embedding_model_version</code>、<code>embedding_endpoint_url</code>，理想情况下还应该有一个在索引时由该端点生成的固定金丝雀字符串（canary string）的内容哈希。你的写入路径拒绝插入没有这些字段的向量。你的读取路径拒绝针对模型标识符与查询来源不匹配的文档进行评分。错误应该是响亮且即时的，而不是无声的召回率下滑。</p>
<p><strong>将金丝雀字符串视为契约测试。</strong> 挑选一小组固定的字符串 —— 20 到 50 个即可 —— 在构建索引时嵌入每一个字符串，并将生成的向量作为契约制品存储。在查询时，针对采样的一部分请求，重新嵌入其中一个金丝雀字符串，计算其与存储向量的余弦相似度，并断言其高于某个阈值（如 0.9999）。一旦提供商的端点开始为已知固定输入返回实质上不同的向量，断言就会触发。这是你能针对无法控制的提供商部署的最廉价的行为变更检测器。</p>
<p><strong>监控 recall@k 的斜率，而非仅仅是数值。</strong> 一个常设的标注查询集（200 到 500 个查询-文档对就足以发挥作用）应该每晚针对实时检索路径运行 —— 使用当天的端点生成查询嵌入，针对实际存在的索引进行检索 —— 并报告 recall@k。警报不应针对绝对数值，而应针对多日滚动斜率。如果阈值是在系统健康时设置的，那么六周内 47% 的下滑对于任何基于阈值的警报来说都是不可见的；但在趋势图上，它一目了然。</p>
<p><strong>运行强制切换登记簿，而非弃用跟踪器。</strong> 一个记录“提供商说这将在日期 T 消失”的弃用仪表盘会助长拖延。而一个记录“我们将在日期 T 减去 90 天前完全迁移出此端点”的切换登记簿则将压力放在了你的时间线上。强制切换日期应该根据提供商的日期倒推，并留出利润空间，以涵盖你必须重新嵌入的语料库、必须重新运行的评估以及必须切换的索引。如果你无法按期完成，你会足够早地发现并与提供商协商，而不是在需要紧急修复时才姗姗来迟。</p>
<p><strong>将重新嵌入项目规划为基础设施，而非冲刺任务。</strong> 重新嵌入一个耗时一个季度构建的语料库，将耗费相当的时间和成本。现代化的指导意见将其视为重大的基础设施事件：并行索引、双写阶段、比较新旧索引结果的影子查询阶段、带有回滚计划的切换，以及仅在新索引承载全部流量一周后才弃用旧索引。这项工作量巨大，如果你因为召回率崩溃而在周五才发现需求，那么你已经陷入麻烦了。</p>
<p>对于那些确实无法及时重新嵌入的团队，最近的研究探索了可学习的转换层（learnable transformation layers），这些图层将新模型的查询嵌入映射到旧索引的空间中，以少量的延迟开销恢复完整重新嵌入的大部分召回率。这些作为迁移期间的过渡桥梁很有用，但不能替代迁移 —— 它们只是掩盖了几何不匹配，而非修复它。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="事故背后的架构教训">事故背后的架构教训<a href="https://tianpan.co/zh/blog/2026-06-03-the-embedding-deprecation-that-halved-your-retrieval-recall-without-a-deploy#%E4%BA%8B%E6%95%85%E8%83%8C%E5%90%8E%E7%9A%84%E6%9E%B6%E6%9E%84%E6%95%99%E8%AE%AD" class="hash-link" aria-label="事故背后的架构教训的直接链��接" title="事故背后的架构教训的直接链接" translate="no">​</a></h2>
<p>这里有一个值得总结的泛化规律：提供商 API 的契约表面（contract surface）是提供商在不更改 URL 的情况下可以更改的所有内容。对于嵌入端点，该表面包括从文本到向量的实际映射，而这恰恰是你的检索系统所依赖的东西。将 URL 视为依赖项是一个 bug。依赖项是映射关系，而 URL 只是你调用它的方式。</p>
<p>同样的泛化规律也适用于你根据模型输出进行索引的任何内容：你缓存其评分的重排序器（re-rankers）、你持久化其标签的分类器、其输出被连接到下游流水线的生成摘要器。在这些场景中，你都将模型的输出写入了慢速存储，而你的查询路径正在从一个仅对你负有形状责任而非语义责任的快速端点读取数据。在公告和停用之间的窗口期内，提供商行为发生变化并不少见 —— 它们反而是常态。问题在于你的系统是否能察觉到这些变化。</p>
<p>能够妥善处理这些问题的检索系统有三个共同点。他们为每个存储的向量标记产生它的模型身份。他们运行金丝雀行为变更检测，针对几何偏移（geometry drift）而非运行时间（uptime）报警。他们将自己的强制切换日期视为真正关键的截止日期，而将提供商的停用日期视为一个宽松的上限。没有这三样东西的团队，其召回率底线正被供应商以团队评估无法察觉的节奏重新谈判 —— 一年后的某个下午，团队中的某人将花费漫长的时间，将质量倒退追溯到一个在任何提交记录中都未曾改变过的 URL。</p>]]></content>
        <author>
            <name>Tian Pan</name>
            <uri>https://tianpan.co</uri>
        </author>
        <category label="rag" term="rag"/>
        <category label="embeddings" term="embeddings"/>
        <category label="retrieval" term="retrieval"/>
        <category label="vendor-risk" term="vendor-risk"/>
        <category label="observability" term="observability"/>
        <category label="deprecation" term="deprecation"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[团队上线了新提示词模板，评估框架却还在测昨天的旧版本]]></title>
        <id>https://tianpan.co/zh/blog/2026-06-03-the-eval-harness-that-ran-on-yesterdays-prompt-template-after-your-team-shipped-a-new-one</id>
        <link href="https://tianpan.co/zh/blog/2026-06-03-the-eval-harness-that-ran-on-yesterdays-prompt-template-after-your-team-shipped-a-new-one"/>
        <updated>2026-06-03T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[如果评估套件在给错误的提示词版本评分，即使发布版本已损坏，报告仍会显示通过。解决方案不在于更快的缓存失效，而在于使用基于内容的提示词哈希，从而从根本上杜绝评估与生产环境出现偏差的可能性。]]></summary>
        <content type="html"><![CDATA[<p>事件时间线清晰可见。9:02，你的平台团队将 <code>prompt-template@v38</code> 推送到了配置服务。11:14，你的仪表板显示一切正常。16:51，支持团队有人标记了升级件数的激增。17:03，你打开了评估套件，发现回归分数为 0.34，于是进行了回滚。复盘报告称：“在 8 小时内捕获，除了 0.04% 看到该问题的客户外，未造成进一步损害。”工程领导层对响应速度表示赞赏。</p>
<p><img decoding="async" loading="lazy" src="https://opengraph-image.blockeden.xyz/api/og-tianpan-co?title=%E5%9B%A2%E9%98%9F%E4%B8%8A%E7%BA%BF%E4%BA%86%E6%96%B0%E6%8F%90%E7%A4%BA%E8%AF%8D%E6%A8%A1%E6%9D%BF%EF%BC%8C%E8%AF%84%E4%BC%B0%E6%A1%86%E6%9E%B6%E5%8D%B4%E8%BF%98%E5%9C%A8%E6%B5%8B%E6%98%A8%E5%A4%A9%E7%9A%84%E6%97%A7%E7%89%88%E6%9C%AC" alt="" class="img_ev3q"></p>
<p>但这是错的。回归在 0 小时内就被捕获了。17:03 运行的评估套件与 09:03 运行的是同一个。它一直指向的是 <code>v37</code>。评估框架在进程启动时从配置服务加载了模板，将渲染后的 Prompt 以 Python 对象的形式缓存到了模块级作用域中，并且从未重新读取源文件。你的线上流量在上午 9 点切换到了 <code>v38</code>。而你的评估直到 17:03 有人重启了 Worker 池来“重新运行回归”时才发生变化。在长达 8 小时的时间里，客户交互是基于从未经过评估打分的 Prompt 进行的，而评估系统却一直在给生产环境中根本没人在用的 Prompt 打分。</p>
<p>这种故障模式没有任何仪表板能捕捉到，因为两个系统都在按自己的标准报告成功。评估套件是健康的：它运行了，产生了分数，它没有拦截任何东西，因为没有请求要求它拦截。Prompt 版本控制系统是健康的：<code>v38</code> 是当前版本，请求日志确认了这一点，5% 的金丝雀发布也顺利完成。出问题的是它们之间的连接——即“评估正针对生产环境中的 Prompt 运行”这一假设——而连接并没有被监控，因为没有人负责它们。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="没人知道那是个缓存">没人知道那是个缓存<a href="https://tianpan.co/zh/blog/2026-06-03-the-eval-harness-that-ran-on-yesterdays-prompt-template-after-your-team-shipped-a-new-one#%E6%B2%A1%E4%BA%BA%E7%9F%A5%E9%81%93%E9%82%A3%E6%98%AF%E4%B8%AA%E7%BC%93%E5%AD%98" class="hash-link" aria-label="没人知道那是个缓存的直接链接" title="没人知道那是个缓存的直接链接" translate="no">​</a></h2>
<p>配置服务的存在是为了消除在每次请求时读取配置文件的延迟。你将 Prompt 集中在 LaunchDarkly 的 AI 配置、Braintrust 的 Prompt 库或基于 Redis 的内部服务中。你提供了一个抓取最新版本的客户端 SDK。你宣称客户端很“快”，因为它有缓存。而“缓存”在实践中通常意味着：客户端在构造时抓取一次，然后在进程重启前一直信任内存中的副本。</p>
<p>对于随着部署频率而变化的 Prompt 来说，这种约定没问题——每次推送都会重启所有 Worker，缓存会隐式失效，没人会察觉。但一旦 Prompt 独立于代码发布，它就会失效。将 Prompt 移至配置服务的初衷就是为了让 Prompt 工程师在无需代码部署的情况下进行迭代。这意味着缓存失效问题现在成了核心支柱，而你的 SDK 提供的答案——“重启进程”——与平台旨在实现的流程是不兼容的。</p>
<p>评估框架意外地继承了这个缺陷。它使用相同的 SDK，持有相同的缓存副本，并运行在一个长期存在的 Worker 池上，而该池的存在正是为了避免在每次运行时重新构建评估图的成本。Worker 存活的时间越长，你就越有信心认为“评估流水线是稳定的”，而缓存的模板与生产环境的偏差也就越大。框架的稳定性恰恰是产生偏差的原因。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="自我衡量的指标">自我衡量的指标<a href="https://tianpan.co/zh/blog/2026-06-03-the-eval-harness-that-ran-on-yesterdays-prompt-template-after-your-team-shipped-a-new-one#%E8%87%AA%E6%88%91%E8%A1%A1%E9%87%8F%E7%9A%84%E6%8C%87%E6%A0%87" class="hash-link" aria-label="自我衡量的指标的直接链接" title="自我衡量的指标的直接链接" translate="no">​</a></h2>
<p>更深层的问题是，由过时评估报告的回归分数在内部是一致的。评估系统针对黄金数据集给 <code>v37</code> 打分。<code>v37</code> 曾针对该数据集进行过调优。分数是 0.91。这个分数已经保持了好几周。只要评估系统继续给 <code>v37</code> 打分，无论 <code>v38</code> 或 <code>v39</code> 在生产中表现如何，分数都会一直是 0.91。没有异常可以报警，因为在评估系统所能看到的世界里，唯一改变的只有采样噪声。</p>
<p>你可以通过一个思想实验来确认这一点。如果 Prompt 服务在今年余下的时间里对每个消费者（评估、生产、金丝雀，所有人）都静默返回 <code>v37</code>，你的仪表板也不会有任何波动。你的评估分数会保持平稳。你的 Prompt 版本管理界面会显示 <code>v38</code> 为“激活”状态。你信任用来捕捉回归的指标，对于它所评估的系统是否就是你正在运行的系统，并没有任何发言权。它不可能有发言权，因为它的输入中没有任何东西能强迫它察觉到这一点。</p>
<p>这就是评估与生产环境脱节的结构性特征：离线评估是针对固定数据集验证固定产物。它们的设计初衷就是不随之变化。当被测试的产物在无声无息中与生产环境的产物脱钩时，正是这种让离线评估具有可重复性的设计，让它们对这种脱钩视而不见。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="评估了错误的系统究竟代价几何">“评估了错误的系统”究竟代价几何<a href="https://tianpan.co/zh/blog/2026-06-03-the-eval-harness-that-ran-on-yesterdays-prompt-template-after-your-team-shipped-a-new-one#%E8%AF%84%E4%BC%B0%E4%BA%86%E9%94%99%E8%AF%AF%E7%9A%84%E7%B3%BB%E7%BB%9F%E7%A9%B6%E7%AB%9F%E4%BB%A3%E4%BB%B7%E5%87%A0%E4%BD%95" class="hash-link" aria-label="“评估了错误的系统”究竟代价几何的直接链接" title="“评估了错误的系统”究竟代价几何的直接链接" translate="no">​</a></h2>
<p>这 8 小时的窗口期本身并不重要，重要的是期间得出的结论。产品团队问：“新 Prompt 有帮助吗？”评估系统说：“没变化。”工程团队问：“我们应该发布 v39 吗？”评估系统说：“v38 没问题，继续迭代吧。”Prompt 工程师在评估仪表板上查看 v38 与 v37 的对比，没看到有意义的差异，于是得出结论：这次改动影响中性——这感觉就像是获准在当前基础上发布下一次改动的通行证，因为上一次是中性的。</p>
<p>等到支持团队反馈现实世界的行为时，团队已经在他们认为中性、实则发生了回归的基础上堆叠了另外三次 Prompt 改动。回滚不再是简单的“撤回 v38”，而是要“搞清楚 v38、v39、v40 和 v41 中到底是哪一个出了问题，毕竟它们中没有任何一个曾针对大家以为正在测试的数据集进行过评估”。</p>
<p>修复成本并非 8 小时对客户的影响，而是整整一周因评估证据失效而作废的 Prompt 迭代，以及工程团队对评估记分板的信任——一旦人们看到它在版本发布出现故障时依然报告正常，这种信任就很难迅速恢复。那种廉价的定性——“我们的评估滞后了 8 小时”——掩盖了昂贵的现实，即在该窗口期内发布的每一次 Prompt 更改都必须手动重新评估，且团队在未来的几个月里都会对评估系统产生怀疑。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="强制链路真实性">强制链路真实性<a href="https://tianpan.co/zh/blog/2026-06-03-the-eval-harness-that-ran-on-yesterdays-prompt-template-after-your-team-shipped-a-new-one#%E5%BC%BA%E5%88%B6%E9%93%BE%E8%B7%AF%E7%9C%9F%E5%AE%9E%E6%80%A7" class="hash-link" aria-label="强制链路真实性的直接链接" title="强制链路真实性的直接链接" translate="no">​</a></h2>
<p>修复缓存是显而易见的举动，但并非正确的切入点。即使你让 SDK 每 30 秒轮询一次变更，你依然没有回答这个问题：“评测环境评分的内容是否正是生产环境当前运行的内容？”你只是缩小了答案为“否”的时间窗口。</p>
<p>真正经得起考验的修复方案是：让评测环境针对生产环境<strong>已运行</strong>的内容进行评分，而不是针对配置服务当前显示的内容。具体而言：生产环境中的每一次 Prompt 渲染都应在输出响应的同时，附带输出已解析的模板哈希（Template Hash）。当评估框架（Eval harness）提取生产追踪（Trace）样本进行打分时，它会从追踪记录中读取模板哈希，并精确复原该模板——既不是缓存中的那个，也不是当前处于活动状态的那个。评测变成了“该请求实际见到的 Prompt”的函数，这使得“漂移（Drift）”失去了存在的可能。如果框架找不到所需的模板，它会直接报错，而不是静默地用昨天的副本代替。</p>
<p>同样的逻辑也适用于离线回归运行。Prompt 变更的 CI 准入门槛不应询问“框架是否对 v38 进行了评分”，而应询问“框架所评分的哈希值，是否与 v38 在生产环境解析出的哈希值一致”。在提交时固定产物（Artifact）。像对待模型版本一样对待 Prompt 版本：不可变、内容寻址、通过哈希而非名称与评估结果关联。正是“活动指针”这一层间接性导致了评测与生产环境的漂移；消除这层间接性，漂移就会从一个隐蔽的报告 Bug 变成一个编译错误。</p>
<p>对于评估框架进程本身，其准则与任何监听可变上游状态的长期运行消费者一致：要么在每次运行时重启，要么将缓存视为由上游负责失效的派生视图。折中方案——“我只持有一个引用并假设它是最新的”——是此类故障中每一个事故的标配。选边站吧。每次调用都冷启动的评测套件虽然消耗更多算力，但从不在评分版本上撒谎；而热启动但数据陈旧的评测套件虽然零成本，却总在关键时刻误导你。</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="能捕获下一次故障的审计">能捕获下一次故障的审计<a href="https://tianpan.co/zh/blog/2026-06-03-the-eval-harness-that-ran-on-yesterdays-prompt-template-after-your-team-shipped-a-new-one#%E8%83%BD%E6%8D%95%E8%8E%B7%E4%B8%8B%E4%B8%80%E6%AC%A1%E6%95%85%E9%9A%9C%E7%9A%84%E5%AE%A1%E8%AE%A1" class="hash-link" aria-label="能捕获下一次故障的审计的直接链接" title="能捕获下一次故障的审计的直接链接" translate="no">​</a></h2>
<p>这种故障模式之所以反复出现，是因为它在任何标准运维手册（Runbook）中都没有体现。没有名为“评测与生产环境 Prompt 偏移”的指标，也没有当框架缓存时长超过部署节奏时触发的告警。团队的思想模型是“评测归评测，Prompt 归 Prompt”，两者之间的集成被视为一根电线，而不是一个系统。</p>
<p>一次简单的审计就能让这根“电线”显形。从过去一小时内随机抽取一个生产追踪记录。提取 Prompt 模板哈希。在同一时间窗的评测评分板中查找对应的追踪。确认评测所用的哈希与生产环境所用的哈希是否匹配。如果你的系统无法在五分钟内回答这个问题，那么评测与生产之间的链接就是隐性的，这意味着它们正以一种尚未被察觉的方式脱节。下一次由于评测陈旧引发的事故已经在路上了，只是你还没看到相关的支持工单。</p>
<p>能在这类问题中幸存的团队并不拥有更聪明的评测方案。他们拥有的是除非能证明评分对象、否则拒绝生成分数的评测流水线，并且他们将这种证明视为交付物的一部分。分数本身只是一个数字。分数加上 Prompt 哈希、加上模型版本、再加上数据集的 Commit，才是一个完整的产物（Artifact）。除此以外的任何东西，都只是上述时间线中的仪表盘——在一个评测流水线从未真正触达的系统上，盲目地亮着八小时绿灯。</p>]]></content>
        <author>
            <name>Tian Pan</name>
            <uri>https://tianpan.co</uri>
        </author>
        <category label="llm-evals" term="llm-evals"/>
        <category label="prompt-engineering" term="prompt-engineering"/>
        <category label="observability" term="observability"/>
        <category label="mlops" term="mlops"/>
        <category label="incident-postmortem" term="incident-postmortem"/>
    </entry>
</feed>