说明:本文讨论的是同一个 AI 能力接入多个产品入口时,答案不一致的来源与治理办法,属于工程架构话题,不涉及具体模型版本与价格。AI 领域版本迭代极快,凡涉及版本号、价格、可用性,请以你阅读时的官方页面为准。文中代码为结构示意,请按自己的技术栈调整后再上生产。

一、不一致是怎么长出来的

先把入口清单落到纸上

收到「网页端和 App 答得不一样」这类反馈时,最不该做的一件事就是直奔提示词去改。先做的是把入口清单写下来。一个能力铺到多个入口,落点通常是这几处:网页对话页、移动 App、对外开放 API、IM 群里的机器人、客服工作台。它们看起来是同一个能力,背后却是四五套独立的技术栈、四五拨不同的维护人。

清单至少要写四列:入口、技术栈、状态形态、维护人。少了「状态形态」这一列,第五章和第六章的问题根本无从谈起——因为状态形态决定了这个入口会不会共享会话、要不要共享缓存。

入口技术栈状态形态常见偏差
网页对话页前端直连后端服务有会话上下文命中会话级缓存
移动 App客户端 → 网关 → 服务有会话上下文版本发布滞后
开放 API三方系统直调无状态,单次请求调用方自带提示
IM 群机器人消息回调 → 服务以单条消息为单位无跨消息上下文
客服工作台内部系统内嵌有工单上下文检索过滤条件不同

四处来源逐一对照

把不一致的来源穷举下来,其实固定是四处,不多不少:

来源具体表现典型触发
版本不同入口调到不同的模型版本灰度发布、分批升级
提示每个入口各写一份 system prompt各团队分别迭代
检索索引版本不同、过滤条件不同知识库分批重建
缓存有的入口命中缓存、有的没有键里带不带入口标识

这四处的特点是:每一处单看都「没问题」,合起来就出错。版本差异只要有一个入口没跟上灰度,两边就分叉;提示各写各的,某个团队顺手加了一句「回答要简洁」,输出风格立刻不同;检索那边索引重建没切齐,同一个问题召回的资料就不是一批;缓存最隐蔽,命中与不命中看起来都正常。

四处叠加之后,不一致几乎是必然

假设四个变量各自有八成的概率在两个入口上一致,四处独立叠加,两边完全一致的概率不到一半。这个估算很糙,但它想说明的事很清楚:多入口不一致不是偶发故障,是默认状态。不做任何收敛动作,两个入口给出不同答案才是常态,一致才是碰巧。

举个具体到不能再具体的例子:网页端提示里加了「回答尽量引用原文」,App 端没加。同一个知识型问题,网页端会附三行出处,App 端给一句结论。两边都没坏,可用户看到的是两个系统——他甚至会怀疑其中一个在编。

所以治理的起点不是「修 bug」,而是承认这套系统本来就会分叉,然后逐个变量把它按回同一口径。

二、把四个变量拆开

变量不拆开,归因永远是猜

最常见的归因方式是看到两边答案不同,就说是「模型不稳定」。这句话没有信息量,因为你无法据此做任何改动。四个变量不拆到可独立观测的粒度,任何「我觉得是提示的问题」都只是猜测。

拆的办法是把每次回答的关键上下文都打点出来,四个变量各留一个字段。

⚠️ 代码待验证

# 每次回答都把四个变量打点,缺一个就没法归因
def record_answer(entry: str, question: str, answer: str, ctx: dict) -> dict:
    return {
        "entry": entry,                          # 入口标识,归因的主键
        "model_version": ctx["model_version"],   # 变量一:版本
        "prompt_id": ctx["prompt_id"],           # 变量二:提示(用 id + 指纹,不存全文)
        "prompt_hash": ctx["prompt_hash"],       # 提示指纹,两边比对看是否同源
        "index_version": ctx["index_version"],   # 变量三:检索索引版本
        "retrieval_filter": ctx["filter"],       # 检索过滤条件,必须显式
        "cache_hit": ctx["cache_hit"],           # 变量四:是否命中缓存
        "cache_key": ctx.get("cache_key"),       # 命中时要能反查键怎么拼的
    }

这段里最容易被漏掉的是 cache_key。缓存命中不一致时,只有拿到键才能知道两个入口为什么一个命中、一个没命中。prompt_hash 而不是提示全文,是为了在日志里既能比对同源、又不把提示原文到处散落。

一次只动一个

拿到打点之后,归因的原则是一次只动一个变量。同时改版本和提示,然后发现答案一致了,你并不知道是哪个改对了,下次还会分叉。

可控的复现方式是固定其他三个,只让一个变量在两个入口上分叉,看答案是否跟着变:

要验证的变量固定住只放开判据
版本提示、索引、缓存两个入口用同一提示、不同版本答案是否随版本变
提示版本、索引、缓存两个入口用不同提示、同版本答案是否随提示变
检索版本、提示、缓存两个入口用不同索引版本召回片段是否不同
缓存版本、提示、索引只让一个入口关缓存关缓存后是否回到一致

归因要留一份可复查的记录

一次归因做完,结论只有「是检索的锅」这句话是不够的。要留下一份能复查的记录:问题、两个入口的四个变量取值、两边答案、以及比对的结论。这份记录后面会直接变成第七章对照跑的问题集来源。

记录里字段的顺序建议固定成入口在前,这样按入口扫不一致率时可以一眼看过去,不用每次重新理解表结构。归因记录和线上打点用同一套字段名,能省掉一层转换——转换的地方越多,越容易在关键时刻对不上。

三、提示与配置的收敛

共享底座 + 入口差异化片段

多入口的提示不能各写一份,但也不能强行合成一份——IM 群里的机器人需要更短的输出,客服工作台需要引用知识源,这些差异是真实需求,硬压平反而会让两头都不好用。

可行的结构是共享底座 + 差异化片段:底座负责身份、安全边界、输出格式这类所有入口都必须一致的约束;差异化片段只放本入口特有的东西。底座改动要走统一评审,片段可以由各团队自行迭代。

⚠️ 代码待验证

# 提示的组装结构:底座唯一,片段按入口声明
prompt:
  base:
    id: base_v3              # 底座版本号,所有入口必须引用同一个
    hash: "b41f..."          # 底座内容指纹,发版时比对
  entries:
    web:
      fragment: web_short    # 只放"输出更短"这类本入口差异
      allow_override: [tone, length]
    api:
      fragment: api_strict   # 严格遵循调用方传入的格式约束
      allow_override: [format]
    im_bot:
      fragment: im_group     # 群聊场景,@ 与转义处理不同
      allow_override: [length]

差异化允许改什么、不允许改什么

这是收敛能不能守住的关键。差异化片段必须有白名单,不能「各入口想改什么改什么」。

维度是否允许入口差异理由
身份与安全边界不允许一旦某入口放宽,安全口径就破了
输出长度与语气允许不同载体天然需要不同长度
输出格式(结构)允许,但要显式声明API 入口常需要严格结构
是否引用知识源允许客服工作台必须引用,群聊可省
拒答与兜底策略不允许属于安全边界的一部分
工具调用权限不允许与第四章的过滤条件联动

白名单外的东西一旦出现在某个入口的片段里,应该在配置加载时就报错,而不是等上线后才发现两个入口口径不同。报错的时机越早,代价越小。

配置要登记、要能 diff

底座和片段都必须是可 diff 的文件,不能散落在代码里、数据库里和某个人的剪贴板里。每次发布前跑一次 diff,用两列告诉评审人:这次改了哪几个入口的哪一段。

底座改动的影响面是所有入口,所以它的评审应该比单个片段更严。反过来,片段变更只需要确认没有碰到白名单外的维度。这里有一个容易踩的坑:有人为了图快,把本该放底座的约束塞进某一个片段的 allow_override 里——白名单校验能拦住它,前提是校验真的跑了。

四、检索与知识源的统一

一份索引多入口消费

检索不一致里最常见的一类,是索引版本不同。同一个知识库,网页端用的是上周重建的索引,客服工作台用的还是上个月的,问同一个问题召回片段自然不同。

治理原则很直白:一份索引,多入口消费。不要在入口这一层各自建索引,入口只决定用什么过滤条件去查,不决定查哪份数据。

如果入口确实需要不同的知识范围(比如客服只看售后文档,API 用户能看全部),那也应该是同一份索引上的不同过滤视图,而不是两份物理索引。两份索引意味着两套重建流程、两套版本号,偏差的种子就此埋下——而且它们会在你最不希望的时候分叉:一次只重建了一半。

过滤条件要显式声明,不能藏在代码里

第二个常见来源是过滤条件隐含在代码里。某个入口的检索函数里悄悄加了一句 doc_type = "help",另一个入口没有。这种差异在代码评审里极易滑过去,因为它是「一行 if」。

做法是把过滤条件从代码里提出来,作为入口配置的一部分显式声明:

⚠️ 代码待验证

# 过滤条件来自入口配置,不允许出现在检索函数体内
ENTRY_FILTERS = {
    "web":     {},                                              # 全量
    "api":     {"doc_type": ["help", "faq"]},
    "support": {"doc_type": ["after_sale"], "region": ["cn"]},  # 客服只看中文售后
    "im_bot":  {"doc_type": ["faq"]},
}


def search(entry: str, query: str, top_k: int = 8) -> list:
    flt = ENTRY_FILTERS[entry]        # 键不存在直接 KeyError,逼你登记
    return index.query(query, filter=flt, top_k=top_k)

这里用 ENTRY_FILTERS[entry] 而不是 .get(entry, {}) 是故意的:新入口没登记过滤条件时应该直接报错,而不是悄悄退化成全量检索。后者看起来兜住了,实际是把一个「该被发现的配置缺失」变成了一个「永远查不到的不一致」。

索引版本要跟着答案一起记录

这节和第二章是连着的。第二章打点里的 index_version 和 retrieval_filter 不是摆设,它们的作用就是在出现不一致时,能直接回答「两个入口是不是查了同一份数据、用了同一条件」。

索引重建完成后,各入口的引用要统一切换,而不是各切各的。切换前后留一份记录,是后面做对照跑时的基线。

五、状态与会话的取舍

哪些入口共享会话、哪些不共享

多入口还有一个容易被忽视的分叉源:状态。网页端有会话上下文,API 侧无状态,IM 里一件事就是一条消息。用户在网页聊了五轮再来 App 问,看到的是两份互不知情的历史——他会觉得「你怎么又不知道了」。

先明确哪些入口共享、哪些不共享。这个判断的依据是入口的交互模型,不是技术方便程度:

入口组合是否共享会话原因
网页 ↔ App共享同一个人、同一账号、连续交互
网页 / App ↔ API不共享API 调用方自己管上下文
网页 ↔ IM 群不共享IM 是单条消息粒度,语义不连续
客服工作台 ↔ 网页默认不共享,可显式拉取工单上下文与个人会话是两回事

判断标准可以浓缩成一条:用户是否认为这两处是「同一次对话」。用户觉得是,你就得接续;用户不觉得,硬接续反而会让他困惑——把客服工单里的内容带进个人对话,那是隐私事故,不是贴心。

跨入口接续的最小传递物

共享会话不等于共享全部历史。全量历史搬来搬去有两个坏处:状态对象越来越重、跨入口传递的内容越来越不可控。

最小传递物应该只包含接续所必需的东西,而不是整段对话:

⚠️ 代码待验证

{
  "session_id": "s_8f21",
  "owner": "user_1024",
  "entries_seen": ["web", "app"],
  "summary": "用户在问报销流程,已确认所在地区,未确认发票类型",
  "open_slots": ["invoice_type"],
  "last_active": 1760000000,
  "turn_refs": ["t_91", "t_92", "t_93"]
}

这份结构里有三个字段值得单独说:

  • summary 是压缩后的接续状态,不是原文。跨入口传原对话既贵又难控制裁剪,而且原文里可能有不该跨入口流动的内容。
  • open_slots 记录了「还没问清楚的点」。App 端接上时可以直接接着问那个缺口,而不是从头把信息问一遍。
  • turn_refs 是引用而非内容。需要详情时按 id 回查,避免状态对象随轮数膨胀。

不共享时怎么把历史带过去

对不共享的入口(比如 IM 群),处理方式不是把会话接过去,而是把结论带过去。用户在 IM 群里问一个问题,机器人可以给出答案;但如果用户接着问「那我上次说的那个呢」,IM 侧无从上文取到。

可接受的折中是:允许用户显式地引用一段对话给另一个入口,作为这一次请求的输入,而不是把这个入口变成隐式共享会话的一端。显式的引用在隐私上可控——用户知道自己带过去了什么;隐式的共享则不可控,也违背 IM 单条消息粒度的模型。

这里有个顺序上的讲究:先定「哪些入口共享」,再定「传递物里放什么」。顺序反过来的话,你会先设计出一个很全的状态对象,然后发现没有任何一个入口真的需要它,写出来的字段最后全是没人读的。

完整版资料清单:本文用到的入口对照表与归因实测记录都整理在里面了,扫码即可获取:

配套资料获取

六、缓存必须有统一口径

键怎么设计才不串

缓存是一致性里最隐蔽的一处。命中缓存的入口返回旧答案,没命中的返回新答案,两边看起来都「正常」,只有用户能看出不对。

问题的根在键。如果键只由问题文本拼成,那么任何入口问同一个问题都会命中同一条缓存,版本、提示、检索的变化全被盖住。正确的键要把影响答案的变量都装进去:

⚠️ 代码待验证

import hashlib


def build_cache_key(question: str, ctx: dict, include_entry: bool) -> str:
    parts = [
        ctx["model_version"],       # 版本变了,缓存必须失效
        ctx["prompt_hash"],         # 提示变了,缓存必须失效
        ctx["index_version"],       # 索引重建了,缓存必须失效
        question.strip().lower(),
    ]
    if include_entry:
        parts.append(ctx["entry"])  # 是否让入口进键,见下一节
    return "ans:" + hashlib.sha256("|".join(parts).encode()).hexdigest()[:24]

四个变量的前三个必须进键,这一点没有争议——它们任何一个变了,答案就不该复用旧缓存。有争议的是第四个:入口要不要进键。

入口标识能不能进键

把入口标识拼进键,直觉上更安全,但它会直接拉低命中率,所以不能无条件做。判断方法很简单:两个入口在给定问题上,是否会因为入口差异给出不同答案。会,就必须进键;不会,就不该进。

几种典型情况分开看。各入口提示不同时,提示指纹已经在键里了,入口标识进不进都能正确失效,进键只是让排查时更直观;各入口检索过滤不同时,过滤条件本身已经在键里,同样不必重复;各入口输出格式不同时,格式不同就意味着答案文本不同,这时入口标识必须进键。反过来看,如果几个入口同源同配置,把入口拼进键只会让本该共用的缓存被切成几份,白白降低命中率。

早先「入口一定进键」和「入口绝不进键」两种一刀切都不对:前者白白拉低命中率,后者在大改提示之后还可能继续返回旧答案。

敏感入口不共享缓存的判据

有一类入口无论变量是否相同,都不应该和别人共享缓存:涉及个人数据、涉及权限隔离、涉及工单上下文的入口。

判据是答案里是否包含只对某个用户或租户可见的内容。如果客服工作台的答案里会带出当前工单的编号和客户信息,那么它的缓存键只由变量和问题拼成是危险的——不同工单的同一问题会互相串。这类入口应该直接关闭共享缓存,或者把租户标识也拼进键。

反过来,那种纯知识型、答案对所有用户相同的入口,共享缓存不仅能提命中率,还能顺带把答案口径统一了——它们命中同一条缓存,就不可能有分歧。缓存在这里既是性能手段,也是一致性手段。

七、怎么知道现在一致了

同问题集对照跑

前面六章做了一堆收敛动作,但「现在一致了吗」这个问题不能靠感觉回答。要有一个同问题集 + 同输入口径的对照跑法。

问题集不是随便凑的,应该来自两处:一是第二章归因记录里真正出现过分歧的问题,二是各入口的高频问题。前者覆盖已知的老毛病,后者覆盖最可能被用户撞见的场景。两者加起来,一份几十条的问题集就够起步。

⚠️ 代码待验证

# 对同一批问题,逐入口跑一遍,收集四变量与答案
for entry in web app api support im_bot; do
  python run_probe.py \
    --entry "$entry" \
    --questions questions.jsonl \
    --record-fields model_version,prompt_hash,index_version,cache_hit \
    --out "probe_${entry}.jsonl"
done

# 比对:同一问题在不同入口的答案是否一致
python compare_probe.py --inputs probe_*.jsonl --group-by question --out diff_report.csv

--record-fields 是关键参数。只比对答案文本是不够的——不一致出现了还要能立刻知道是哪个变量分的叉,所以四个变量必须跟答案一起落盘。跑之前要清一次缓存,否则上一次的缓存命中会污染这一轮的判定。

按入口维度看差异率

对照跑的产出是一张按入口对看的差异率表。看的时候要带上变量维度,否则只看到一个百分比,等着你去猜。

入口对差异率主要归因变量动作
网页 ↔ App高版本(App 发布滞后)收敛发布节奏
网页 ↔ API中提示(调用方自带)底座兜底
网页 ↔ 客服中检索过滤差异显式声明过滤条件
App ↔ API低缓存键含入口保留现状

差异率不是要压到 0。有些分歧是设计允许的——API 入口按调用方传入的格式约束输出,本来就和网页不同。要做的是把允许的分歧和意外的分歧分开,只追后者。把允许的分歧也塞进分母,会让这个指标永远不达标,然后被所有人无视。

把差异率接进发布前检查

对照跑如果只是偶尔手动跑一次,它迟早会变成一个没人看的报告。要做的是把它接进发布流程:任何改动底座提示、模型版本、索引重建的发布,都在发布前跑一次对照跑,差异率超过阈值就拦住。

阈值怎么定也有讲究。一开始的阈值应该宽,等差异率稳定在某个水平之后,再把它降到「比当前水平略高一点」,让它去抓的是回归而不是幻想中的零差异。一个从一开始就设成 0% 的阈值,唯一结果是所有人都学会绕过它。

完整版资料清单:本文用到的入口对照表与归因实测记录都整理在里面了,扫码即可获取:

配套资料获取

附表 A:关键取舍一览

本文涉及的所有工程判断集中在这里,方便按需回看。

取舍本文结论判断依据位置
不一致的定性默认状态,不是偶发故障四处变量叠加,一致概率低于半数第一章
归因前提四个变量各自打点不拆开就只能是猜第二章
归因动作一次只动一个变量同时改两个无法定位第二章
提示结构共享底座 + 入口差异化片段底座必须全入口唯一第三章
差异化范围走白名单,越界报错安全边界不允许入口差异第三章
配置存放可 diff 的文件,纳入发布评审散落各处无法比对第三章
索引形态一份索引多入口消费多份索引等于多套版本第四章
过滤条件从代码提到入口配置显式声明藏在代码里极易漏审第四章
跨入口会话按「是否同一次对话」判断硬接续会造成隐私事故第五章
接续传递物摘要 + 缺口 + 引用,不传原文全量历史太重且不可控第五章
缓存键版本/提示/索引必须进键三者任一变化都该失效第六章
入口是否进键按是否会造成答案差异决定一刀切都不对第六章
敏感入口缓存关闭共享或拼入租户标识答案含个人可见内容会串第六章
一致性判定同问题集 + 同输入口径对照跑靠感觉判断不可复查第七章
差异率阈值先宽后收,抓回归不追零定成零会被绕过第七章

附表 B:术语速查表

术语含义
入口消费同一个 AI 能力的产品形态,如网页、App、API、IM 群
四处来源多入口不一致的固定成因:版本、提示、检索、缓存
变量打点每次回答都记录四个变量取值,供归因使用
提示底座所有入口必须共享的提示部分,承载身份与安全边界
差异化片段入口特有的提示片段,范围受白名单约束
差异化白名单允许入口自行调整的维度清单
一份索引知识库物理上只有一份索引,入口只改变过滤视图
显式过滤条件检索过滤条件登记在入口配置中,不写在检索函数里
会话接续把共享会话的状态从一个入口传递给另一个入口
最小传递物接续所需的最小状态集合:摘要、缺口、轮次引用
开放槽位会话中尚未问清、需要继续追问的点
缓存键决定缓存命中与否的字符串,由影响答案的变量拼接而成
对照跑用同一问题集、同一输入口径逐入口跑一遍的一致性检查
差异率同一问题在不同入口答案不一致的比例
发布前检查把对照跑接进发布流程,差异率超阈值则拦截

写在最后:这篇用到的资料

写这篇文章时,我把多个入口的一致性来源与排障路径重新梳理了一遍,顺手整理成几份配套的东西:

  • 大模型学习路线图:从 LLM 基础到 Agent 开发,各阶段该学什么、用什么资料
  • 大模型全套教程:按主题分好的视频与文档清单
  • 大模型实战好书:24 本,附每本适合的阶段

资料是我自己整理的,放在下面这个码上,扫码即可获取:

配套资料获取

大模型全套教程

大模型实战好书

添加时备注「大模型」,优先通过。

拿到之后建议先看学习路线图那一份,先定位自己在哪个阶段,再决定学什么,比一上来就啃框架效率高得多。

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐