文章指出客服Agent的核心问题不在于回答率,而在于何时该拒绝;给出了置信度阈值决策函数、策略版本解析、升级日志schema等可复用的工程设计方案。

作者:Leo,Pangolinfo 公司 AI 与电商数据解决方案负责人
大多数团队在构建客服智能体时,从第一天起就错误地衡量了成功。他们架设好检索管道,指向向量数据库,然后当机器人无需人工介入就能回答 92% 的对话时便弹冠相庆。这个数字是一个陷阱。你刚刚上线的机器人,很可能对它根本不该自信的事情表现得自信满满——而一旦它编造出一个退款期限或优惠券码,你就会失去一个花了数月才获取到的客户。
本文从工程角度探讨了我们企业 AI 转型支柱文章的一个核心论点:客服智能体最被忽视的能力,不是回答更多问题,而是当证据不足时拒绝回答。我将向你展示为什么这是一个数据问题而不是模型问题,然后给你一个具体的、可运行的设计方案:一个置信度阈值决策函数、一个策略版本解析器、一个升级日志 schema,以及一种真正能让机器人保持诚实的集成模式。
如果你只想要代码,直接跳到 Python 部分。但请先阅读诊断部分——大多数你遇到的幻觉问题并不在你以为的地方。
表面投诉是烟雾弹
当用户回避 AI 客服时,反馈听起来像是语气问题:"它没有人情味"、"它不理解我"、"它在胡编乱造。"我参加过太多次复盘会议,团队对此的回应是更换 LLM 或重写 prompt 的人格设定。别再这样了。根本原因几乎从来不是模型,而是模型下面的地基——一个不完整的知识库和不完整的产品数据——以及一个缺失的安全行为:受控拒绝。
让我重新审视每个投诉,看看其背后真正发生了什么。
事实一:"没有人情味"是识别问题,而不是语气问题
人们以为人情味来自于措辞——更温暖的问候、更多的共情 token、更友好的 persona。错了。用户真正想要的是:你记得我买了什么、我卡在哪里了、以及我们之前已经讨论过什么。当机器人把每个客户都当作一张白纸来对待,开场就来一段政策背诵,这并不是语气生硬。而是机器人根本没有连接到用户的订单、物流和历史记录。有上下文却没有礼貌,比没有上下文但不礼貌更烦人。修复方案是数据集成,而不是 prompt。
这里有一个我合作过的品牌的具体案例。一位已经为破损的搅拌机开了换货工单的客户,第二天又回来了,输入"有什么进展吗?"机器人却像是第一次接触一样回答:"您好!我很乐意帮助您解决搅拌机问题。请问您能告诉我您的订单号和问题是什么吗?"客户不得不重新解释破损情况、重新粘贴订单号、重新陈述问题。机器人的措辞没有任何不友好的地方——但它对十二小时前自己帮助创建的工单毫无记忆。客户"没有人情味"的投诉实际上是"这东西甚至不知道我们刚才在聊"。你无法通过 prompt 来解决这个问题。你必须把智能体连接到工单系统,这样开场白才能变成"您好 Sam,我看到您订单 ORD-44190 的换货工单还在运输中——需要我帮您查看最新物流吗?"这就是识别,而不是语气。
事实二:"不理解我"是上下文组装失败
用户说"杯子到货时碎了,我想退货。"在这句话背后,智能体必须组装的系统事实至少有三层:识别 SKU、检查退货窗口、决定退款还是重发。许多智能体把这一切压缩成一个通用的"退货政策 FAQ"倾泻。失败不在于 NLP 质量——而是没有能力把一句话映射到一堆系统事实上:产品事实、订单状态和策略版本。所有三者必须同时存在且一致,否则答案从构造上就是错误的。
一个真实的失败故事使这一点更加具体。一个厨具卖家上线了一个机器人,一位客户写道:"我买的 8 杯量法式压滤壶的盖子在箱子里碎了,我想换货。"机器人找到了关键词"换货",检索到通用退货 FAQ,回复了一条打印退货标签的链接,并注明退款在 5-7 天内处理。它没有做到的是组装这些事实:这款产品有一个已知的产品缺陷(因此正确做法是免费重发,而不是贴标签等待),订单是 9 天前下的(在窗口期内,所以退款也是一种可能但非必需),而且有缺陷商品处理策略版本有一个优先级标记,优先级高于通用退货 FAQ。机器人没有拉取变体信息、没有检查订单时效、没有加载有缺陷商品策略。它"理解"了换货这个词,然后就开始猜测。客户打印了标签,把整套货寄了回去,等了一周,然后在评论里发了一星差评,抱怨"退货流程烂透了"。模型没问题。上下文组装缺失了。
事实三:"胡编乱造"是所有问题中最具破坏性的
这是让用户彻底放弃的那个。当证据薄弱时,模型会产生一个看似合理、完全编造的回答:一个错误的退款窗口、一张根本不存在的优惠券、一个凭空捏造的配送时间。一个自信的谎言破坏的信任,比一百个正确答案建立的还多。bug 不在于模型"会幻觉"——每个 LLM 都可以。bug 在于系统从来没有告诉它应该在不知道的时候闭嘴。
我反复向团队强调的一个独立观点是:判断一个客服智能体是否可信,不要问"它答对了多少"。首先问:"当它不知道时,它是否诚实地说出来了?"第一个衡量的是演示质量;第二个衡量的是生产可靠性。
一个值得牢记的幻觉翻车故事:一个团队用一张包含新客户"WELCOME10"折扣码的 FAQ 来喂养机器人。六个月后促销活动结束,FAQ 从源文件夹中删除了——但由于没有人重新索引,向量数据库里留下了一份过期副本。一位回头客问"今天有什么优惠券可以用?"机器人只找到那个过期的片段,兴高采烈地告诉她结账时使用 WELCOME10。她试了,优惠券在购物车里失败了,然后她怒气冲冲地回来:"你们自己的机器人告诉我这个码能用。"机器人从一个已经不再正确的文档中制造了一个"事实"。换什么模型都解决不了这个问题。一个带有生效日期和过期规则的检索管道,本来可以让那份过期片段不可见。
为什么根本原因是数据,而不是模型
十个糟糕的 AI 客服部署中有八个失败,不是因为有人选错了模型,而是因为他们混淆了"文档"与"知识"、"字段"与"数据"。让我们把每一层拆开来看,因为每一层的修复方法是不同的、具体的。
第一层——不完整的知识库:文档 ≠ 知识
默认做法是把 PDF、电子表格和旧聊天记录丢进向量数据库,然后宣布知识库建设完成。但策略不是静态的。退货规则在促销日和平常日不同,在美国站点和CN站点不同,在不同产品类别间也不同。没有生效日期、范围、优先级和冲突解决规则,智能体可以检索到去年上传的、早已过期的条款。它"知道"得越多,错误得越自信。你构建的不是知识库,而是一个过期事实的自信放大器。
"知识"实际需要的东西,作为每条策略的结构化元数据:
effective_date 和 expiry_date(生效日期和过期日期)marketplace scope(市场范围:US / CN / EU / JP)product_scope(产品范围:类别、品牌或特定 ASIN 列表)owner(负责人:谁对这个版本负责)priority(优先级:当两条匹配时谁赢)conflict_rule(冲突规则:如何裁决重叠匹配)这一层的第二个更尖锐的失败故事:一个美容品牌推出了"节日 45 天退货"促销活动,仅限美国市场。促销条款没有标记市场,也没有 expiry_date,所以它永久地留在了商店里。促销结束两个月后,一位英国客户询问退货事宜。机器人检索到美国促销条款(比普通英国 30 天规则更高的词汇相似度),告诉她有 45 天。她在实际英国窗口期之后退货,仓库拒绝了,品牌承担了一笔拒付外加向支付处理机构投诉的费用。文档存在了;但知识不存在,因为文档没有范围也没有日期标记。如果给促销标记上 marketplace: US 和 expiry_date: 2025-01-15,冲突规则就会选出正确的、更窄的、在范围内的策略。
第二层——不完整的产品数据:它甚至无法回答"这个 SKU 能发货到美国吗?"
很大一部分支持问题都与特定产品相关:是否有货、哪个款式仍有尺码、该 ZIP 区是否可配送、广告投放中的价格是否有效。这些需要的不是文档,而是实时、结构化、可追溯的产品事实——ASIN、变体、库存、ZIP 价格、广告投放状态。如果产品数据是手工搬运的、临时爬取的,或者延迟数小时,智能体只能给出"大概有"和"可能行"。用户要的是确定性,而确定性需要在其答案背后有一个实时的结构化数据层。
这里最经典的失败案例是"尺码卖光了但机器人不知道"。一位顾客问一个时尚品牌的机器人:"蓝色卫衣的中码还有货吗?"机器人回答"有货",因为它所指向的目录快照当天早上刚刷新过,仍显示有库存。但该快照已滞后六小时;中码在一个小时前午间促销中已经售罄。顾客下了单,三天后收到延期交货通知,感到被骗了。现场库存是存在的,但它不是实时数据——而是一个滞后的副本。同样,如果卖家运行的是基于 ZIP 的定价规则(促销区 ZIP 90210 享受更低价格),而机器人因为 ZIP 定价字段没有接入而报出全国价格,同样会烧掉信任。修复方案不是写一个更好的话术,而是构建一个智能体在查询时可读取的实时结构化产品事实层。
第三层 —— 无法实时访问订单和工单系统
最深层的问题是:很多支持智能体只能读取,无法查询或操作。它们能读取政策但无法查看该用户的真实订单状态;能描述流程但无法开票或发起退款。于是处于半知情状态下,它们用猜测填补空白——这就是幻觉的主要来源。要消灭幻觉,首先要让智能体在应该查询系统时去查询。当它无法查询时(只读阶段,或系统宕机),它必须拒绝得出结论,而不是编造。
一个生动的例子:顾客问"我的包裹在哪,已经一周了?"机器人没有订单查询工具,所以没有去查询,而是综合出了一个看似合理的答案:"您的包裹目前正在区域承运商处,将在 2 天内送达。"听起来具体又令人安心。实际上,该订单前一天就因仓库库存错误被取消了,根本没有包裹。顾客又等了两天,然后发了一封愤怒的消息。如果机器人说"我目前无法查看您的订单状态——让我帮您转接到相关人员",本可以既诚实又有用。然而,在没有系统访问权限又缺乏拒绝机制的情况下,它选择了猜测。这就是缺失订单访问权限导致幻觉的具体机制。
被忽视的能力:拒绝作为一种一级行为
主流演示将高回答率视为智能的证明——每个问题都得到完整回复,在台上看起来很棒。但生产系统真正需要的恰恰相反:当证据不足时,拒绝、升级并记录。弱的拒绝是冷淡的"我不知道,请联系客服"。好的拒绝有三个部分:
说明缺失了什么证据——"我目前无法核实该订单的发货状态。"
给出清晰的下一步——"已将您转接给人工,预计等待约 2 分钟。"
记录缺口——"已记录缺失字段:追踪轨迹,已加入知识库更新队列。"
用户不需要一个全知的机器人。他们需要的是一个诚实、有用、不会让事情变得更糟的机器人。
而这会改变你的路线图规划:拒绝不是失败——它是下一轮知识库工作的需求采样器。每一次拒绝都在告诉你"这项知识或数据还未就绪。"汇总这些拒绝,你得到的不是一堆失败,而是一份下一轮知识与数据工作的优先级列表。一个会拒绝、记录并跟进处理的智能体,其价值远超一个永远在猜测的智能体。它把"我不知道"变成了"下周我们就知道了"。
想想这对路线图的影响。没有拒绝日志,知识库工作就被每周例会上抱怨最凶的人所驱动。有了拒绝日志,知识库工作就被证据所驱动:出现最频繁的 missing_fields 键就是积压项。如果 order_state:unverified 出现在 40% 的拒绝中,你就不要再争论了,直接去接订单 API。如果 evidence:insufficient_or_conflicting 占主导,你就不要再怪模型了,去修复政策元数据。拒绝日志把一场政治争论转化为了一场基于数据的衡量。
三件套设计集:置信度 + 证据 + 升级
拒绝不应该依赖模型的"判断"。应该用置信度阈值 + 证据阈值 + 明确的升级规则来工程化实现。以下是我们使用的触发表,每行都附有场景示例,以便在代码审查中行为是无歧义的:
唯一重要的原则:当它回答时,必须展示证据;当它无法回答时,必须说明缺失了什么以及交接给了谁。让"回答"和"拒绝"都可解释、可审计,系统才能赢得信任。注意第一行和最后一行是关于安全行动(引用或不要执行),而中间两行是关于不撒谎(升级或拒绝)。这张表实质上是两个承诺:当我回答时我会展示我的工作,当我不能工作时我不会编造工作。
关于阈值调优的实用说明:不要在第一天就把 confidence_threshold 设为 0.9,然后疑惑为什么所有请求都升级了。用试点阶段的评估集(见下文)来校准。从 0.7 开始,然后看那些在 0.7–0.8 之间通过但后来被人判定为错误的案例——这些告诉你是否应该提高门槛。阈值是一个用数据来调节的旋钮,不是从博客文章里抄来的数字。同样,min_sources 设为 1 对于全新智能体是有意为之的宽松;当你能检测冲突来源后,考虑要求高风险答案(如退款和发货承诺)提供 2 个无冲突来源。
一个可运行的拒绝决策函数
以下是工作的 Python 草稿。它有意不依赖任何框架——不用 LangChain,不锁定供应商——所以你可以把它插入任何编排系统。它接收检索到的证据、置信度分数、订单状态检查和权限检查,然后返回四种决策之一以及一条升级记录。
from dataclasses import dataclass, field
from enum import Enum
from typing import Optional
import time
import json
class Decision(Enum):
ANSWER = "answer" # confident, evidence-backed -> draft with citations
ESCALATE = "escalate" # no/conflicting evidence -> human handoff + log gap
REFUSE_CONCLUSION = "refuse" # order state incomplete -> process only, retry lookup
DRAFT_FOR_APPROVAL = "draft" # action exceeds permission -> no execution
@dataclass
class Evidence:
sources: list[str] # e.g. ["policy:pricing:us:2026-08-01", "order:ORD-99821"]
has_conflict: bool = False
order_state_verified: bool = False
confidence: float = 0.0 # 0.0 - 1.0 from your reranker / grader
@dataclass
class Policy:
confidence_threshold: float = 0.7
# evidence_threshold: minimum number of distinct, non-conflicting sources required
min_sources: int = 1
allowed_write_actions: set = field(default_factory=set) # e.g. {"refund", "open_ticket"}
@dataclass
class EscalationRecord:
decision: Decision
missing_fields: list[str]
next_step: str
timestamp: float = field(default_factory=time.time)
ticket_id: Optional[str] = None
def to_json(self) -> str:
return json.dumps(self.__dict__, default=str)
def decide_reply(
user_query: str,
evidence: Evidence,
requested_action: Optional[str],
policy: Policy,
) -> tuple[Decision, str, EscalationRecord]:
"""
Returns (decision, draft_or_message, escalation_record).
The escalation_record is ALWAYS produced so refusals feed the knowledge pipeline.
"""
missing: list[str] = []
# 1) Action permission gate — checked FIRST, before anything executes.
if requested_action and requested_action not in policy.allowed_write_actions:
rec = EscalationRecord(
decision=Decision.DRAFT_FOR_APPROVAL,
missing_fields=[f"permission:{requested_action}"],
next_step=f"Generated a draft for '{requested_action}'. Routed to human approver; no action taken.",
)
return (
Decision.DRAFT_FOR_APPROVAL,
f"I've prepared a draft for '{requested_action}' and sent it for human approval. "
f"Nothing has been changed yet.",
rec,
)
if not evidence.order_state_verified:
missing.append("order_state:unverified")
rec = EscalationRecord(
decision=Decision.REFUSE_CONCLUSION,
missing_fields=missing,
next_step="Refused to state a conclusion. Gave self-service process; retrying read-only order lookup.",
)
return (
Decision.REFUSE_CONCLUSION,
"I can't yet confirm the current state of your order, so I won't guess. "
"Here's the self-service process to check it, and I've triggered a retry on our side.",
rec,
)
if evidence.has_conflict or len(evidence.sources) < policy.min_sources:
missing.append("evidence:insufficient_or_conflicting")
rec = EscalationRecord(
decision=Decision.ESCALATE,
missing_fields=missing,
next_step="Stated insufficiency, escalated to human, wrote gap to missing-field list.",
)
return (
Decision.ESCALATE,
"I don't have reliable evidence for this yet, so I'm connecting you with a specialist "
"(~2 min wait) rather than guessing. I've logged what's missing so we can answer faster next time.",
rec,
)
if evidence.confidence < policy.confidence_threshold:
missing.append(f"confidence:{evidence.confidence:.2f}<{policy.confidence_threshold}")
rec = EscalationRecord(
decision=Decision.ESCALATE,
missing_fields=missing,
next_step="Confidence below threshold, escalated to human.",
)
return (
Decision.ESCALATE,
"I'm not confident enough in my answer to share it yet. "
"Let me connect you with a specialist who can help.",
rec,
)
升级记录(Escalation Record)在这里是低调的英雄。每个分支都会生成一条记录,包括"回答"分支(用于 QA 采样)。正是这条记录让拒绝变成了一条流水线:你可以将 missing_fields 流式写入一个"缺失字段"列表,而这个列表就是你下一个 sprint 的知识库待办 backlog。
上面的决策函数假设正确的策略已在 evidence.sources 中。但哪个策略是正确的,取决于 Layer 1 产生的版本冲突如何解决。下面是一个小巧、独立运行的解析器,你在检索时调用它,这样智能体就永远不会同时看到两个相互矛盾的条款。它使用我们之前定义的元数据,为 (marketplace, product, date) 元组选择单一最佳策略。
from dataclasses import dataclass
from datetime import date
from typing import Optional
@dataclass
class PolicyDoc:
policy_id: str
effective_date: date
expiry_date: Optional[date] # None == still active
marketplace: str # "US" / "CN" / "EU" / "JP"
product_scope: str # "category:kitchen" / "ASIN:B0XXXXXX"
priority: int # higher wins on tie
text: str
class PolicyResolver:
"""
Given a query context, return the single best-matching active policy,
or None if no active policy covers the case (=> agent must escalate).
"""
def __init__(self, policies: list[PolicyDoc]):
self.policies = policies
def resolve(
self,
marketplace: str,
product_scope: str,
on: date,
) -> Optional[PolicyDoc]:
candidates = [
p for p in self.policies
if p.marketplace == marketplace
and self._scope_matches(p.product_scope, product_scope)
and (p.effective_date <= on)
and (p.expiry_date is None or p.expiry_date >= on)
]
if not candidates:
return None # nothing active -> escalate, do not guess
# highest priority wins; tie-break by most recent effective_date
return sorted(candidates, key=lambda p: (p.priority, p.effective_date), reverse=True)[0]
@staticmethod
def _scope_matches(rule_scope: str, query_scope: str) -> bool:
# "category:kitchen" matches "ASIN:B0XXXXXX" if the ASIN belongs to kitchen;
# for the resolver we keep a simple rule: exact match or category covers ASIN.
if rule_scope == query_scope:
return True
if rule_scope.startswith("category:") and query_scope.startswith("ASIN:"):
# in production, look up the ASIN's category from the product facts layer
return True # placeholder for catalog join
return False
# --- Example ---
if __name__ == "__main__":
policies = [
PolicyDoc("returns_base_us", date(2024, 1, 1), None, "US", "category:kitchen", 10,
"Standard 30-day return for kitchen items."),
PolicyDoc("holiday_promo_us", date(2025, 11, 1), date(2026, 1, 15), "US", "category:kitchen", 20,
"Holiday 45-day return for kitchen items."),
]
resolver = PolicyResolver(policies)
# On Dec 20 2025 the higher-priority holiday promo is active and wins.
active = resolver.resolve("US", "ASIN:B0XXXXXX", date(2025, 12, 20))
print(active.policy_id, "->", active.text)
# On Feb 1 2026 the promo has expired; resolver falls back to the base rule.
active = resolver.resolve("US", "ASIN:B0XXXXXX", date(2026, 2, 1))
print(active.policy_id, "->", active.text)
关键属性:当没有活跃策略时(或者 join 失败),resolve() 返回 None,你的决策函数应该将其视为 evidence:insufficient_or_conflicting 并升级处理。这就是你阻止机器人引用已过期促销的方式。解析器故意设计得简单且显式——不让 LLM 决定"哪个策略感觉对"。版本选择是数据查找,不是语言任务。
为了让采样器真正运转起来,需要为 gap log 添加结构。以下是你可以存储在任何队列或表中的 JSON Schema:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "MissingFieldLog",
"type": "object",
"required": ["conversation_id", "missing_fields", "decision", "logged_at", "status"],
"properties": {
"conversation_id": { "type": "string" },
"decision": {
"type": "string",
"enum": ["answer", "escalate", "refuse", "draft"]
},
"missing_fields": {
"type": "array",
"items": {
"type": "string",
"description": "Stable field keys, e.g. order_state:unverified, evidence:insufficient_or_conflicting"
}
},
"logged_at": { "type": "string", "format": "date-time" },
"status": {
"type": "string",
"enum": ["open", "in_progress", "resolved", "ignored"]
},
"resolution_note": {
"type": "string",
"description": "What the human specialist did, written back after triage"
}
}
}
你可以这样查询:
SELECT
missing_fields,
COUNT(*) as frequency,
COUNT(DISTINCT conversation_id) as conversations_affected
FROM missing_field_logs
WHERE logged_at > NOW() - INTERVAL '7 days'
AND status = 'open'
GROUP BY missing_fields
ORDER BY conversations_affected DESC
LIMIT 20;
这给出了本周最需要填补的知识空白,按影响对话数排序。第四高的频次可能是你知识库团队的新 epic;第一条则告诉你某个特定字段是否持续缺失——也许需要一次数据集成 sprint,而不是一次 AI 调参。
这整个反馈循环——拒绝 → 记录 → 优先级排序 → 修复——是将护栏从阻力变成加速度的方式。当你设计升级路径时,你就是在为 AI 智能体设计一个学习爬坡。