拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

没Token能用,有Token更聪明:企业RAG客服双态检索实战

没Token能用,有Token更聪明:企业RAG客服双态检索实战

去年做企业技术支持 Agent 的时候,产品经理甩给我一句话:“用户不想登录也得能问问题,但登录以后你必须答得更准确。”这句话翻译成技术语言,就是一套基于 RAG 的客服系统,必须同时支撑匿名态和认证态两种检索路径。而这两条路径的分水岭,就是 Token。

这套方案我们最终跑通了,效果很符合标题里那句话:没 Token 能用,有 Token 更聪明。游客不登录,也能基于公开知识库拿到通用答案;登录用户带着 Token 进来,RAG 就不再是“百科式检索”,而是能结合他的企业身份、历史工单、私有文档去做个性化回答。下面我把整个实战拆开讲,包括双态架构设计、匿名与认证链路实现、Token 生命周期管理、检索质量调优,以及我们踩过的一些坑。正在做 RAG 知识库、企业客服助手或者内部 Agent 的团队,可以参考。

1. 为什么“没 Token 能用,有 Token 更聪明”不是一句口号

1.1 需求拆开看:一个入口,两种服务

这个项目刚启动时,所有人都以为我们在做一个“聪明点的 QA 机器人”。但真正跑起来才发现,产品要的是两个东西:

  • 对于匿名访客:能快速回答产品公开 FAQ、帮助文档里的通用问题,解决“这个功能在哪”“怎么配置”这类基础疑问;
  • 对于登录用户:能结合他的租户配置、订阅套餐、最近工单、使用记录,回答“为什么我的任务失败了”“我这个环境该怎么调参”这类个性化问题。

同样一句话“任务失败了”,匿名态只能给出通用排查步骤:“请检查网络、检查任务配置、查看日志”。认证态就能给到更切实的方向:“您的账号在近 1 小时内有 3 个任务失败,集中在节点 A,且错误码都是权限不足,建议先检查凭据是否过期。”

这两种回答的差异不是模型能力造成的,而是检索范围和信息密度的差异。用户有没有带 Token,直接决定了 Agent 能从知识库里捞到什么,以及能调取哪些用户上下文。

1.2 为什么不能只用一套 RAG 通吃

有人会问:把公开文档和私有文档都塞进同一个向量库,检索的时候不区分身份,不就行了吗?不行,原因很实际。

  • 权限边界会被击穿。私有文档、客户专属配置、故障复盘,这些内容如果出现在匿名用户的回答里,就是安全事故。
  • 检索噪音会变大。把所有文档混在一起,匿名用户问一个通用问题时,可能召回一堆无关的私有文档,反而拉低答案精度。
  • 成本不可控。认证态链路通常需要做多轮检索、工具调用、更长上下文,如果所有匿名流量都走这条链路,Token 消耗会非常快。

所以我们的结论是:必须做成双态路由。同一个 Agent 入口,根据请求头里有没有 Authorization Token,决定走哪条检索链路。

1.3 落地时定的四条硬指标

不管产品怎么描述,工程上最终要落到指标。我们当时定了四条:

  • 匿名请求成功率不低于 99%,延迟 P95 控制在 3 秒以内;
  • 登录用户的回答必须带上用户上下文或私有知识引用,不能和匿名回答一样;
  • 任何情况下,匿名链路不能返回私有知识片段;
  • Token 过期或异常时,系统要能优雅降级到匿名链路,而不是直接报错。

后面所有章节的内容,都是围绕这四条指标展开的。

2. 匿名态检索链路:让游客先“能用”起来

2.1 公开知识库的数据源与清洗

游客能不能“能用”,核心是公开知识库的质量。我们当时的数据源比较典型:官网产品文档、公开 FAQ、版本发布说明、可公开的社区答疑帖。

这些数据格式五花八门,有 HTML 页面、Markdown 文档、PDF、甚至表格截图。直接切 chunk 塞向量库是不行的,会污染检索结果。我们做了几件事:

  • 把 HTML 统一转成 Markdown,去掉导航、页脚、JS 动态内容;
  • 把表格按行拆成“问题-答案”结构,而不是整表切碎;
  • 给每个文档贴上元数据标签:source(来源)、version(适用版本)、category(文档分类)、visibility=public。

其中visibility=public这个标签很重要,后面认证态做权限过滤时会用到。

2.2 选型:嵌入模型与向量库

匿名链路是流量的主体,选型要兼顾准确率、延迟和运维成本。我们对比过几套组合,简单说下结论:

方案优点缺点适用场景
托管 API 嵌入模型效果好,维护省事数据要过外网,费用随量涨数据敏感度低、预算充足
本地开源嵌入模型(BGE-M3 等)数据不出内网,量大成本低需要 GPU 或好 CPU,效果略逊数据隐私要求高、量很大
关键词检索(ES/OpenSearch)精确匹配强,无需训练语义理解弱,同义改写无效作为混合检索的补充

向量库方面,我们前期用了 Chroma 做原型,验证链路通不通。后来因为要按租户过滤,换成了支持 metadata filter 的 pgvector,跟业务库放一起运维更简单。数据量再大的话,Milvus 或 ES 的向量索引会更合适,但中小企业项目用 pgvector 足够了。

2.3 匿名检索链路的核心实现

匿名链路本身不复杂,关键在于“检索不到时要有兜底”。这是 Python 伪代码的思路:

from fastapi import FastAPI, Request from langchain_community.vectorstores import PGVector app = FastAPI() public_retriever = PGVector( collection_name="public_kb", connection_string="postgresql://...", embedding_function=embedding_model ).as_retriever( search_kwargs={"k": 4, "filter": {"visibility": "public"}} ) def generate_answer(question, docs, user_context=None): context = "\n\n".join([d.page_content for d in docs]) prompt = f"""基于以下资料回答问题。如果资料不足,请直接说明。 {f"用户上下文:{user_context}" if user_context else ""} 资料: {context} 问题:{question} """ return llm.invoke(prompt) @app.post("/api/agent/chat") def chat(request: Request): body = await request.json() question = body["question"] auth_token = request.headers.get("Authorization", "").removeprefix("Bearer ") # 匿名态:纯公开知识库检索 + 通用兜底 docs = public_retriever.invoke(question) if not docs or docs[0].metadata.get("score", 1) < 0.35: return {"answer": _fallback_answer(question), "mode": "anonymous", "source": "fallback"} answer = generate_answer(question, docs) return {"answer": answer, "mode": "anonymous", "source": [d.metadata.get("source") for d in docs]}

2.4 兜底策略:宁可承认不会,也不要硬编

匿名用户的问题千奇百怪,公开知识库不可能全覆盖。我们当时定了两个兜底级别:

  • 低分兜底:检索结果分数低于阈值时,不让模型硬读不相关内容生成答案,而是走通用大模型直接回答,并提示“此回答为通用建议,仅基于公开资料”;
  • 无召回兜底:完全没有命中时,返回固定话术,引导用户查看人工客服入口。

这里最忌讳的是“强行回答”。一次错误的回答,比一次承认不会更伤害用户信任。而且匿名态本来就定位为“能用”,不需要追求满分,给游客一个可用的初步答案就够了。

3. 认证态检索链路:Token 解锁私有知识边界

3.1 JWT 里该放什么:claims 设计决定检索边界

有 Token 之后的“聪明”,不是靠模型变强了,而是靠 Token 里携带的身份信息扩大了可检索范围。我们用的 JWT,核心 claims 长这样:

{ "sub": "u_12345", "org_id": "org_6789", "role": "admin", "license": "enterprise", "iat": 1700000000, "exp": 1700003600 }

这几个字段直接决定检索策略:

  • org_id用于多租户隔离,向量检索时强制加 filter;
  • role控制知识库可见性,比如admin能看到内部部署文档,普通用户只能看产品通用文档;
  • license决定是否启用高级知识库,比如企业版才有 SLA 和故障排查专家知识;
  • exp用于校验有效期。

我见过不少团队把 JWT 当成一个“能证明登录”的令牌就完事了,其实它更像一把钥匙,钥匙上刻着你能进哪个房间。

3.2 多租户权限过滤:在检索之前做,不要在生成之后做

认证态链路和匿名态最大的区别,就是在向量检索时加了权限过滤。注意,这个过滤一定发生在检索阶段,不能在召回之后再让模型判断“能不能用”。

打个比方:你进图书馆查资料,管理员应该只让你进你有权限的书架,而不是把你带进所有书架,然后叮嘱你“别把禁书说出去”。模型在生成阶段很难保证百分百不泄漏,所以我们从源头控制。

伪代码如下:

def auth_query_docs(question, user_claims): org_filter = {"org_id": user_claims["org_id"]} # 根据 role 决定可见性范围 visibility_scope = ["internal", "public"] if user_claims["role"] == "admin": visibility_scope.append("admin_only") return vector_store.similarity_search( question, k=6, filter={ "$and": [ org_filter, {"visibility": {"$in": visibility_scope}} ] } )

不同向量库的 filter 语法略有差异,但思路一致:把租户和角色条件变成硬过滤条件,回不来就是回不来。

3.3 用户上下文拼装:让模型看到“这个人”

私有知识库只是第一步。更“聪明”的地方在于,我们把用户的业务数据也组装进 Prompt。

具体做法是:从 Token 里的sub和org_id出发,调用用户服务拿到最近 N 条工单、当前使用的版本、最近登录设备等信息。然后拼成一段“用户上下文”放到 Prompt 最前面。

示例输出:

用户上下文: - 用户账号 u_12345,所在组织 org_6789,使用企业版 2.4 版本 - 最近 24 小时有 2 条工单:T-1024(失败)、T-1025(排队中) - 当前节点:node-a,历史失败原因多为 permission denied 问题:我的任务为什么一直失败?

有了这个上下文,模型就不再是“读过很多文档但不知道你是谁”的机器人,而是一个真正了解你情况的技术支持。

3.4 从 RAG 升级到 agentic RAG

认证态链路只做“检索-生成”其实还不够。到后期,我们把 Agent 能力加了进来,变成了 agentic RAG。区别在于:

  • 纯 RAG:根据问题去知识库检索,然后生成回答;
  • Agentic RAG:Agent 先判断“这个问题该查文档、查工单系统、还是查服务状态”,然后动态选择工具,把多个来源的信息组合起来回答。

举个例子,用户问“我的任务在 node-a 上失败了”。Agent 会:

# 伪代码,表示 Agent 的工具调度逻辑 if "失败" in question and "任务" in question: ticket_info = get_recent_tickets(user_id) # 查工单系统 service_status = check_service("node-a") # 查服务状态 docs = auth_query_docs(question, claims) # 查私有知识库 answer = generate_answer(question, [ticket_info, service_status, docs])

这个过程中,每个工具都需要用户 Token 去调用相应的业务接口。可以说,Token 不仅是检索的钥匙,也是整个 Agent 工具链的准入凭证。

4. Token 全生命周期:从签发、校验到失效降级

4.1 校验流程:服务端无状态校验,但不裸校验

认证态链路能不能稳定,Token 生命周期管理是命门。我们的校验逻辑很简单:

  • 网关收到请求后,从 Authorization Header 提取 Bearer Token;
  • 不查数据库,直接校验 JWT 签名和exp;
  • 签名公钥通过 JWKS 端点获取,并做本地缓存(5 分钟刷新一次)。

JWT 校验的错误处理要注意clock skew。服务器时间和签发服务器时间可能相差几十秒,导致明明没过期的 Token 被判定为过期。我们当时给leeway设置了 30 秒,有效缓解了偶发 401。

4.2 Access Token 与 Refresh Token:为什么必须分开

单 Token 方案在用户长期使用时非常痛苦:有效期设短了,用户频繁重新登录;设长了,被盗风险又大。我们用的是常用的双 Token 方案:

类型有效期存放位置用途
Access Token15 分钟前端内存调用 Agent 接口的凭证
Refresh Token7 天(可续期)HttpOnly Cookie换取新的 Access Token

Refresh Token 必须用 HttpOnly Cookie 存储,不能放进 localStorage,否则 XSS 攻击可以直接偷走长凭证。前端在收到 401 后,自动调用刷新接口:

# refresh_token 由 Cookie 自动携带 @app.post("/api/auth/refresh") def refresh_token(): refresh_token = request.cookies.get("refresh_token") if not refresh_token: raise HTTPException(status_code=401, detail="refresh_token empty") # 校验 refresh_token 并签发新 access_token new_access_token = auth_service.exchange_refresh_token(refresh_token) return {"access_token": new_access_token}

4.3 高频故障排查:那些 “token exchange failed” 到底因为什么

在热词里经常看到这些报错:sign-in could not be completed token exchange failed、failed to refresh token: 400 bad request: invalid 'refresh_token'、your access token could not be refreshed。我们把它们整理成一张排查表:

常见报错可能原因处理建议
401 token expired / could not be refreshedAccess Token 过期、Refresh Token 过期或已吊销前端自动刷新;刷新失败则引导重新登录
400 invalid refresh_token empty string请求没带 Cookie 或 Cookie 被清除检查 HttpOnly Cookie 是否设置正确,跨域时是否带credentials: include
403 token exchange failed客户端凭证错误、IP 白名单不匹配、风控策略拦截检查认证中心的 client_id/secret、回调地址配置,不要尝试绕过风控
500 token endpoint error认证中心服务异常、网络抖动客户端做指数退避重试;服务端检查日志和依赖服务健康状态

这里特别想提醒一句:不要把认证链路当成“一次成功,永久有效”的黑盒。你必须在 Agent 侧把 Token 失效当作正常情况来处理,而不是当作异常。

4.4 优雅降级:Token 不行时退回匿名链路

前面提到,产品要求“游客也能用”。这意味着,即使 Token 失效,系统也不能直接抛 401——用户只是登录态丢了,不等于不能给你服务了。

我们的策略是三级降级:

  • Token 有效:走认证态链路,全部能力开放;
  • Token 刷新失败:删除本地失效凭证,提示“登录状态已过期,已切换为通用回答模式”,同时走匿名链路继续回答;
  • 无 Token:正常走匿名链路,必要时提示“登录后可获取更精准的个性化解答”。

这个设计牛在哪?它让 Token 成为“能力增强器”而不是“门禁锁”。用户遇到 token 问题时,不会觉得这个系统“坏了”,只会觉得“登录后更好用”。

5. 检索质量调优:把 hit rate 从 60% 拉到 80% 的实战

5.1 hit rate 到底怎么定义

RAG 项目做久了都会发现,最终效果好不好,不取决于模型,而是取决于“检索到底能不能命中该命中的文档”。我们在这个项目里用了一个简单直接的指标:hit rate@K。

定义如下:针对一条测试问题,人工标注出它对应的“黄金文档”。系统检索 Top K 结果中如果包含黄金文档,就算一次命中。hit rate = 命中次数 / 总问题数。

我们基线 K=5 时 hit rate 只有 62%,也就是说四条问题里有一条,最相关的文档根本没被召回。这个水平直接导致答案质量不稳定。

5.2 chunk 策略:不是切得越碎越好

一开始我们把文档按固定 512 字符切块,结果表格和代码片段经常被从中间切断,检索时上下文不完整。后面改成按文档结构切块:

  • 先按 Markdown 标题层级切分成小节;
  • 小节再超出 500 token 时,用滑动窗口叠加 80 token 重切;
  • 每个 chunk 保留父级标题信息作为前置摘要。

比如一个文档的 chunk 可能是:

[父级标题:配置安装] [子标题:Windows 环境变量设置] 设置 PATH 时需要注意...

这样检索到的片段自带上下文结构,模型生成时不容易“断章取义”。

5.3 Embedding 模型选型:开源和托管 API 怎么选

我们分别用通用 API 嵌入模型和本地开源模型测过。结论是:

模型hit rate@5延迟备注
API 文本嵌入模型76%30ms(网络)效果好,但数据外送
BGE-M3(本地)74%60ms(GPU)接近 API,数据不出内网
纯关键词匹配55%10ms精确词命中强,语义弱

考虑到企业技术支持里的很多文档涉及内部故障复盘,我们最后选了本地 BGE-M3 作为主力嵌入模型,同时保留关键词检索做混合召回。

5.4 混合检索 + 重排:hit rate 提升的关键

Embedding 换完之后 hit rate 到了 74%,还是不够。后来我们上了“正菜”:混合检索 + 重排。

流程如下:

  • 向量检索召回 Top 50;
  • 关键词检索(BM25)召回 Top 50;
  • 两类结果合并去重;
  • 用轻量级重排模型(cross-encoder 类型)对合并结果逐条打分;
  • 取重排后 Top 5 作为最终上下文。

重排模型虽然要额外算一次,但因为只需要对 Top 100 打分,延迟可控。这一步直接把 hit rate@5 拉到了 83%。代价是总链路延迟多了约 300ms,对客服场景完全可以接受。

5.5 评估集建设:别拍脑袋,要建立回归基线

你可以调一天的参数,也可能只是“感觉效果好了一点”。没有评估集,一切都是玄学。我们当时从真实用户会话里抽了 120 条问题,人工标注出黄金文档,做成离线评估集:

  • 上线前先跑一次基线,记录 hit rate 和答案质量;
  • 每次改 chunk 策略、换 embedding、调 filter 后,重跑同一评估集;
  • 答案质量再抽 30 条做人工打分,从 1 到 5 分。

这里提醒一下:hit rate 提升不完全等于用户满意。有些问题即使命中了文档,模型也可能生成错误结论。所以离线指标只能帮你过滤掉明显变差的改动,最终还是要看线上用户反馈。

6. 上线后的三件大事:可观测、审计、灰度

6.1 可观测性:每个请求都要能回溯

RAG 系统的黑盒感很强,用户说“回答不对”,你很难定位是检索没召回、排序排错、还是模型生成跑偏。我们上线前就做了结构化日志,每个请求记录以下字段:

字段示例说明
request_id8a3f4d链路追踪 ID
has_tokentrue/false走了哪条链路
retrieved_doc_idsdoc_101, doc_203实际召回了哪些文档
hit_score0.82重排后最高分
modeanonymous/auth最终生效的链路
latency_ms1820链路时延
token_usage1234模型 Token 消耗

有了这些日志,用户反馈一条问题,我们就能直接定位是哪一环出了问题。有一次用户投诉“回答和文档对不上”,查日志发现召回文档 ID 是旧的废弃文档,原因是文档下线时没有同步清理向量库索引。没有结构化日志,这个问题根本查不出来。

6.2 权限审计:定期用工具扫一遍泄露风险

双态 RAG 最怕的是权限漏洞。我们的审计手段很朴素,但很有效:

  • 准备一组“跨租户探测问题”,例如 A 租户用户问“B 租户的故障报告怎么解决”;
  • 用匿名会话和认证会话分别调用接口,收集返回内容;
  • 检查是否有任何响应片段包含非授权租户的文档特征(文档 ID、专属名词);
  • 每周跑一次,发现问题立即下线对应 chunk。

不要相信“模型不会说出来”。只要私有文档进入了上下文,模型就有可能在生成时引用它。所以审计的核心不是检查模型,而是确认私有文档根本没有被检索到。

6.3 冷启动与灰度:别第一天就全量开放认证态

我们第一次上线认证态链路时,内部测试没问题,但放量后立刻暴露了问题:某些管理员角色的用户召回范围比普通用户大,导致同一问题在不同角色下的回答风格差异太大。所以后面我们调整了放量节奏:

  • 第一周:只开放匿名链路,公开知识库先跑通;
  • 第二周:开放内部员工认证态,角色限 admin;
  • 第三周:逐步对真实客户开放,先 5% 流量,观察日志和用户反馈;
  • 满一周后:全量开放,同时保留一键切回匿名链路的开关。

这套灰度节奏虽然慢,但能保证问题在可控范围内暴露。RAG 项目不怕出问题,怕的是出了问题只能全量回滚。

做完整套系统再看,“没 Token 能用,有 Token 更聪明”本质是一条朴素的分层设计原则:Token 不是 RAG 的装饰品,而是知识边界和个性化能力的分界线,有了它,Agent 就能从“对所有人讲一套话”变成“对每个人讲他最需要的话”。一个很实用的经验是把 Token 失效当成常态来设计,让系统在“有 Token”和“没 Token”之间平滑切换,而不是二选一。架构上宁可先跑通匿名链路,再叠加认证增强能力,也不要第一版就追求大而全。

返回列表