
去年做企业技术支持 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文档分类、visibilitypublic。其中visibilitypublic这个标签很重要后面认证态做权限过滤时会用到。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_namepublic_kb, connection_stringpostgresql://..., embedding_functionembedding_model ).as_retriever( search_kwargs{k: 4, filter: {visibility: public}} ) def generate_answer(question, docs, user_contextNone): 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用于多租户隔离向量检索时强制加 filterrole控制知识库可见性比如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, k6, 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 RAGAgent 先判断“这个问题该查文档、查工单系统、还是查服务状态”然后动态选择工具把多个来源的信息组合起来回答。举个例子用户问“我的任务在 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 TokenRefresh 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_code401, detailrefresh_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: include403 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 rateK。定义如下针对一条测试问题人工标注出它对应的“黄金文档”。系统检索 Top K 结果中如果包含黄金文档就算一次命中。hit rate 命中次数 / 总问题数。我们基线 K5 时 hit rate 只有 62%也就是说四条问题里有一条最相关的文档根本没被召回。这个水平直接导致答案质量不稳定。5.2 chunk 策略不是切得越碎越好一开始我们把文档按固定 512 字符切块结果表格和代码片段经常被从中间切断检索时上下文不完整。后面改成按文档结构切块先按 Markdown 标题层级切分成小节小节再超出 500 token 时用滑动窗口叠加 80 token 重切每个 chunk 保留父级标题信息作为前置摘要。比如一个文档的 chunk 可能是[父级标题配置安装] [子标题Windows 环境变量设置] 设置 PATH 时需要注意...这样检索到的片段自带上下文结构模型生成时不容易“断章取义”。5.3 Embedding 模型选型开源和托管 API 怎么选我们分别用通用 API 嵌入模型和本地开源模型测过。结论是模型hit rate5延迟备注API 文本嵌入模型76%30ms网络效果好但数据外送BGE-M3本地74%60msGPU接近 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 rate5 拉到了 83%。代价是总链路延迟多了约 300ms对客服场景完全可以接受。5.5 评估集建设别拍脑袋要建立回归基线你可以调一天的参数也可能只是“感觉效果好了一点”。没有评估集一切都是玄学。我们当时从真实用户会话里抽了 120 条问题人工标注出黄金文档做成离线评估集上线前先跑一次基线记录 hit rate 和答案质量每次改 chunk 策略、换 embedding、调 filter 后重跑同一评估集答案质量再抽 30 条做人工打分从 1 到 5 分。这里提醒一下hit rate 提升不完全等于用户满意。有些问题即使命中了文档模型也可能生成错误结论。所以离线指标只能帮你过滤掉明显变差的改动最终还是要看线上用户反馈。6. 上线后的三件大事可观测、审计、灰度6.1 可观测性每个请求都要能回溯RAG 系统的黑盒感很强用户说“回答不对”你很难定位是检索没召回、排序排错、还是模型生成跑偏。我们上线前就做了结构化日志每个请求记录以下字段字段示例说明request_id8a3f4d链路追踪 IDhas_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”之间平滑切换而不是二选一。架构上宁可先跑通匿名链路再叠加认证增强能力也不要第一版就追求大而全。