同一能力多个入口:答案不一致怎么治
说明:本文讨论的是同一个 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 本,附每本适合的阶段
资料是我自己整理的,放在下面这个码上,扫码即可获取:
添加时备注「大模型」,优先通过。
拿到之后建议先看学习路线图那一份,先定位自己在哪个阶段,再决定学什么,比一上来就啃框架效率高得多。
DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐




所有评论(0)