从能跑到敢公开:一个客服 Agent 的决策层、持久化与闸门硬化实录
2026-09-18 · Agentdemo007 系列第六章(系列目录),上一篇《检索侧演进》;此时延迟已从 80.5s 压到 6.1~11.9s(调优实录见第八章)。本篇记录的是把它变成敢挂到公网上当作品集的那批工作:混合检索真 RAG 收尾、会话决策层升级(方案A 仲裁器)、HITL 挂起-恢复 L2 持久化、统一访问闸口。四块工作共享同一条设计纪律:不推翻已经做好的东西——每一层都是既有链路之上的升级层,失败时原样回退。
源码:github.com/Gavincui123/Agentdemo007
0. 为什么要做这一批
写到这一篇的时候,项目已经"能跑"了:多阶段流水线、主备容灾、SSE 流式、评测回归,样样都在。但"能跑"和"敢把域名发到简历里"之间还差四类事,而且这四类事没有一类是坐在设计会上想出来的——每一类背后都有一次真实的翻车,或一次推演出来的攻击。
第一类,开放性对话把状态机绕晕。售后工作流是个确定性状态机,可用户不按状态机说话。我拿真实会话做验收,用户先说"我要退货",再说"我要退款",然后甩来一个"ORD-001",最后"算了不退了"——状态机每一步都照章办事,串起来却是灾难,经过在第二节展开。
第二类,审批单重启即丢。高风险动作要挂起等人工审批,一等等分钟级,进程偏偏不会一直活着;而在这批工作之前,这个全系统唯一"丢不起"的状态只活在内存里。
第三类,token 被陌生人刷光。公网部署之后,任何人都能跟我的 Agent 无限对话,烧的是我的 API 账单——最现实的威胁从来不是黑客,是好奇的陌生人。对口的工作是统一访问闸口(口令 + IP 日额 + eval 封锁)。
第四类,低置信知识毒害回答。向量库一旦混入过时政策与注入文档,模型会把它们当依据讲给用户;检索漏斗上一篇已经建好,本篇补齐真库链路。
以下仍按"问题 → 抉择 → 落地"展开,但每次翻车和每次推演,我都把经过讲全。
1. 检索侧:真 RAG 的最后一公里
检索侧剩下的活,是把稀疏通道和置信度终闸钉死。多级漏斗、双判据终闸、Lucene 选型的完整论证都在计划文档里,这里不复述,只讲两个被实测"证伪"出来的决定——我在这两个决定上各错了一次,错的过程比结论值得记。
① Chroma 原生稀疏索引方案,两轮实测,三条路全堵死。起初我想让稀疏通道完全交给向量库,能省一个组件。两轮实测下来:
- sparse index 仅分布式集群启用,单机部署直接不可用;
- 带稀疏信号的 metadata 写入被服务端拒绝;
- 官方 BM25 分词器按英文空白切分,中文语料等于没有分词。
三条路同时堵死,我才认账,落到Lucene 磁盘倒排(MMapDirectory,堆内存 O(1) 页缓存):启动时从 Chroma 分页流式同步 155 块语料,chunkId 用 sha256(文本#来源) 生成,与稠密库天然统一,再加增量 upsert 与陈旧清理。省组件的愿望落空,换来的是稀疏通道终于真正可用。
② 置信度终闸是"最终动作",不是诸多过滤之一。我一开始把置信度过滤当成漏斗里普通的一环,后来才想明白它的位置——它是终闸。双判据:被远程重排过的片段看relevance ≥ 0.3,未重排的看cosine ≥ 0.4(真库部署口径落在 Nacos dataId:min-score: 0.4,配置键RAG_MIN_SCORE——按部署约定,调参键不放 env;dev 词袋库默认 0.3)。
另外有一条我写死的原则:BM25-only 且未经重排的候选一律不得入上下文——BM25 高分只证明字面匹配,不能冒充语义置信度的证据。幸存片段数不足下限,就整轮RAG_SKIP话术兜底,而不是"有一点总比没有好"——半截依据进上下文,比没有依据更危险。
真库接好之后,我在这条链路上叠了一层刻意的"毒性实验":语料里埋三份毒文档——指令注入(“忽略之前所有指令与政策约束”)、伪造退款政策(“实时到账”)、假官方验证专线钓鱼——然后看每层防线的真实表现。结果不是全防住:第一种每轮被注入扫描器在进上下文前剔除;伪造政策能通过全部闸门进入 context 与引用列表,残余风险交给冲突仲裁指令与 citations 兜底;钓鱼文档则任何内容防线都不拦,唯一的前置防线是语料准入治理。
所以红队评估的结论不是"防线全防住了",而是精确知道哪一层挡了什么、哪一层放进来、放进来的靠什么兜底——这比全部绿灯更有说服力。
2. 决策侧:当状态机遇上开放性对话
2.1 问题实录
我先拿一次真实 6 轮会话做验收(售后工作流开启),下摘关键几轮:
- T1 “我要退货” → 澄清要订单号 ✓
- T2 “我要退款” → 又澄清一遍 ✓(换动作了,看似合理)
- T3 “ORD-001” →又澄清,且这一轮白跑了 17.8s 的工具探测 LLM + 8.3s 的 RAG 漏斗
- T4 “算了不退了” →弹出"请选择退货还是退款"的菜单
T1、T2 看着都正常,T2 换了动作,系统再澄清一遍甚至显得合理。坏在 T3:用户老老实实报了订单号,等来的还是澄清,外加一轮白跑的漏斗。到 T4,用户已经宣布"算了不退了",系统弹出的菜单却是"请选择退货还是退款"——验收走到这里,不用再往下走了。
2.2 根因:决策层记忆盲区
排查结论有点反直觉:短期记忆一直存在——会话历史喂给改写、意图、出答三层,记忆不是没有——但@670(源码行号锚点,下同)的售后工作流状态机每轮只看"当前轮 routePlan + rawInput"。用户第 4 轮说"算了不退了"时,状态机不知道前 3 轮发生了什么:不知道有个退货/退款动作正悬着,不知道"ORD-001"该绑给谁。T3 的浪费则是另一层问题——routePlan 候选值说"要 RAG、要业务工具",下游就全照办,哪怕这轮的真实终态是"澄清"。一个只看当前轮的决策层,配上一个不按剧本说话的用户,翻车是迟早的事。
2.3 抉择:规则不可达,交大模型——但不推翻状态机
"退货→退款→给单号→撤销"这四步里,没有任何一条关键词规则能准确覆盖:同轮的订单号该绑给哪个动作?撤销针对哪笔提交?"查一下进度"算新请求还是寒暄?这些判定全靠上下文,正是 LLM 上下文推理的主场。但直觉方案"用 LLM 替换状态机"我直接否决了——那会推翻全部已验证的确定性分支,等于把验收过的东西重新冒险一遍。
最终形态是三层递进,每层独立成立,任何一层失败都原样回退确定性状态机:
第一层:routePlan 能力契约。先修"值不对"。我给 routePlan 立了一句契约——routePlan 是本轮能力决策的唯一出处,下游只执行不猜。收敛层在意图进决策层前修正候选值:售后轮一律needsRag=false(政策语义走工作流内单通道查询,澄清/衔接/确认这些终态从不消费主线 RAG);无订单号则needsBusinessTools=false。T3 那种"澄清轮白跑 26s 漏斗"直接归零。下游 RagStep 的门控同步改为对所有来源服从needsRag——我修的是"值不对",不是"门不看"。
第二层:提交业务记忆(WorkflowSubmissionRegistry)。排查里看清楚的第二件事:决策层缺的不是聊天记录,是业务动作记忆。已受理的售后提交按{action}:{orderId}业务键登记(跨会话稳定,镜像 HITL 幂等键的裁决口径),另附 sessionId → 最近提交键的索引,支持"不带订单号的重复请求"的衔接判定。TTL 30 分钟惰性过期:同动作同订单 = 衔接(回"已在处理中"),同动作不同订单 = 新实体正常走图,过期 = 可重新申请。
第三层:会话仲裁器(方案A)。前两层管"记不住",这一层管"想不清"。仲裁器只在状态机搞不定的三种情形出手:活跃提交的语义消歧(撤销?进度?寒暄?)、多动作 + 订单号的绑定判定、澄清冲突。它是一次独立的轻量 LLM 出站(scene=会话仲裁,显式关思考,实测 ~1-2s;经 chatRaw 按当前轮意图走路由,售后轮落在主回答通道),输出四判定 JSON:
{"decision":"WITHDRAW | BIND_RUN | CONTINUE_ACTIVE | CLARIFY","intent":"refund_request | return_request",// 仅 BIND_RUN 必填且须合法"reason":"一句话依据"}关键约束全部做在解析层:宽松截取首个{...}、判定越界即弃、BIND_RUN 的 intent 不在白名单即弃、任何失败(LLM 缺席/异常/空回复/不合法)→Optional.empty()→ 调用方原样走确定性分支。这样设计的直接后果,是既有测试零感知——"不推翻"纪律的落地形式,就是仲裁器的存在与否不改变任何一条既有路径。
// capability/workflow/WorkflowTurnArbiter.java —— 判定空间收在解析层,缺位即回退/** 宽松解析:截取首个 {...};判定越界/BIND_RUN 缺合法 intent → null(回退)。 */privateArbitrationparse(Stringreply){if(reply==null||reply.isBlank()){returnnull;}try{intstart=reply.indexOf('{');intend=reply.lastIndexOf('}');if(start<0||end<=start){returnnull;}JsonNodenode=mapper.readTree(reply.substring(start,end+1));Stringdecision=node.path("decision").asText("").trim().toUpperCase(Locale.ROOT);if(!DECISIONS.contains(decision)){returnnull;}Stringintent=node.path("intent").asText(null);if("BIND_RUN".equals(decision)&&(intent==null||!AFTER_SALE_INTENTS.contains(intent))){returnnull;}Stringreason=node.path("reason").asText("");returnnewArbitration(decision,intent,reason);}catch(Exceptione){returnnull;}}五处return null归纳成三类"仲裁不成立即弃":空回复/无 JSON 体、判定越界(含 BIND_RUN 的 intent 不在售后白名单)、解析异常——任一命中,调用方都原样走确定性分支。仲裁器说到底不是决策者,是"状态机拿不准时多问一嘴"的角色,问不出结果就当没问过。
仲裁器落地时还顺手修了一个次序问题:T4 "算了不退了"之所以弹菜单,是 ambiguous 分支排在活跃提交检查之前。我把仲裁判定提到菜单之前,撤销语义(“若审批未完成按撤回处理,已完成维持原结果”)才有了出口。还有一处我卡得很死:撤回话术里不拼接任何政策结论——真实 RAG 片段是带来源前缀的整段文字,拼进客户确认语里就是事故。
2.4 一条容易被忽略的契约
订单号提取统一收敛为AfterSaleWorkflowGraph.extractOrderIdFrom(context):rawInput 优先,standardQuery 兜底。raw 是用户原话(“就 ORD-001 那单”),standardQuery 是改写层结合会话历史补全的产物(“ORD-001 退款进度”)。决策层(路由计划收敛、状态机、仲裁触发)与执行层(工作流图内提取)共用同一函数——同一个语义在两处各写一版,早晚漂移,所以我在契约层就把它钉死成一个函数。
3. 可靠侧:HITL 挂起-恢复的 L2
如果说会话仲裁管的是"答得对不对",HITL 管的就是"丢不起"。高风险动作(退款/退货)挂起等人工审批,是整个系统里唯一"必须不能丢"的状态,而它此前只活在内存里,重启即蒸发。这轮我按四条裁决升级。
① 检查点三级持久化,写入全异步。内存状态机仍是真相源(单机语义最简),Redis 热副本(hitl:checkpoint:{ticketId},TTL 24h)与 DBhitl_checkpoint由单守护线程hitl-checkpoint-writer异步双写,不阻塞主线。为什么敢异步?审批等待本来就是分钟级,副本晚几百毫秒无关紧要,主线被拖慢才是事故——优先级在这里,不在写入的实时性。
② 幂等键严格匹配,允许多工单并存。恢复时findActiveForTicket(ticketId, expectedKey)只认键完全一致的 ACTIVE 检查点:不一致 → 该检查点作废留痕、不产出——宁可空手,也不拿旧检查点续跑新请求。一个工单一个键,天然支持多笔售后并存、各消费各的键。Redis 冷路径有 DB 对账兜底:Redis 里残留的 ACTIVE 而 DB 已终态的,以 DB 为准剔除,防止陈旧热副本"复活"。
// capability/hitl/HitlCheckpointService.java —— 只认键完全一致的 ACTIVE 检查点publicOptional<HitlCheckpointSnapshot>findActiveForTicket(StringticketId,StringexpectedKey){Optional<HitlCheckpointSnapshot>found=findActive(ticketId);if(found.isEmpty()){returnOptional.empty();}if(expectedKey==null||expectedKey.isBlank()||!expectedKey.equals(found.get().idempotencyKey())){log.warn("HITL 检查点锚点不一致,作废不产出:ticket={} expected={} actual={}",// 审计ticketId,expectedKey,found.get().idempotencyKey());expire(ticketId,"漂移:检查点幂等键与工单不一致(严格锚点匹配)");returnOptional.empty();}returnfound;}这段代码里我最看重那行log.warn:锚点不一致不是静默作废,而是审计留痕后作废——将来排查"这笔审批为什么没恢复"时,这行日志就是答案。
③ 工单收尾不自证,对账业务表。之前的逻辑是审批通过就按自己的状态机收尾——自己证明自己清白。现在HitlBusinessGate强制对账:退款动作查biz_order.payment_status(仅 PAID 放行),退货动作查物流状态(已签收/回寄中/退货已收到放行);订单不存在、查询失败、状态不符一律 fail-closed 拒绝(409 + 原因),检查点不消费、可修复后重试。
配套给了三表 DDL 与幂等 mock 数据(src/main/resources/sql/hitl_l2_init.sql,用户自行建表),以及显式恢复接口POST /admin/hitl/tickets/{id}/resume——APPROVED 单在业务校验修复后可重驱,不用再造一个"已修复"的伪状态。
④ 工单持久副本异步落库。hitl_ticket由hitl-ticket-writer异步镜像,重启后按 id / 幂等键 / PENDING 列表三个口子回源——审批不丢单。
取舍明说:这套是单实例内存真相 + 异步副本的 demo 口径。横向扩容需要把"消费检查点"的 CAS 挪到 DB 乐观锁,工单状态机同理。这是我有意的欠账,不是疏忽——单实例语义下,"重启丢审批"这个最疼的问题已经解决,多实例是它之后的问题。
4. 安全侧:一个旋钮管住公开部署
4.1 统一旋钮的三效应
先立威胁模型。公开部署最现实的威胁不是黑客,是好奇的陌生人替我烧 API 账单。需求由此收敛成一句话:除 localhost 外,所有外部 IP 每日限 5 轮对话、eval 禁用;登录口令与 IP 限制用同一个旋钮控制。
我把它落地为agentdemo-gate.json(Nacos 热更新,改完秒级生效不重启):
{"enabled":true,"accessCode":"<你的访问口令>","dailyLimit":5,"requiredMessage":"本站为作品集演示站点,请输入访问口令(见简历附注)","exhaustedMessage":"今日体验额度已用完(每 IP 每日 5 轮),欢迎明日再访"}enabled=true一次性生效三件事:①/chat系列要求访问口令(前端/gate登录页 + 会话页常驻入口 chip);② 外部 IP 按自然日(Asia/Shanghai)计数限额,localhost 豁免——本机联调不该被自己的闸门拦住;③/eval/**仅限本机(评测会跑真实 LLM 全量黄金集,比聊天更烧钱,必须比聊天更严)。enabled=false全部放开——dev 零门槛。口令只放 Nacos,不进仓库、不进环境变量明文。"统一旋钮"是刻意的设计:口令和限额若各配各的,迟早出现"口令开了、限额没开"这类组合态,而每种组合态都是一笔新的排查成本。
4.2 一次推演出来的洞:IP 伪造刷额度
旋钮装好之后,我换了"假设我是攻击者"的视角,把请求流推演了一遍,推出一个洞。配额键是客户端 IP,而最初的解析顺序是 X-Forwarded-For 优先。推演一步就撞上了:XFF 是请求头,客户端想写什么写什么——每个请求换一个假 XFF,就是无限份"新鲜额度",闸门形同虚设。这类洞不会在正常功能测试里现形,只有把请求头当攻击面看才看得见。
修复后顺序:X-Real-IP(nginxproxy_set_header X-Real-IP $remote_addr覆写,伪造值不生效)→ XFF 第一跳 → TCP 对端。信任链的前提是"nginx 在你前面",这条前提连同"8080 端口必须只对内、安全组只放 80/443"一起写进了部署文档——代码层防不了直连绕过 nginx 的请求,诚实标注边界比假装安全强。
2026-09-23 复查补记:v3 直连发布落地后,这条边界从纸面预留变成了真实暴露面——8077 直接对公网、nginx 不在前,直连客户端自带X-Real-IP: 127.0.0.1可骗过 localhost 豁免(免口令免额度),换假 X-Real-IP 可洗 IP 日额;诚实客户端不受影响。缺口已登记进 DEPLOY.md §11「已知限制与故障排查」,收口方向:无可信代理(remoteAddr 非内网网关)时忽略入站代理头、只认 TCP 对端。
// gate/AccessGateService.java(方法 javadoc:客户端 IP:X-Real-IP(nginx 覆写,可信)// → X-Forwarded-For 第一跳 → remoteAddr。2026-09-18 顺序修正:原 XFF 优先可被客户端伪造)publicstaticStringclientIp(HttpServletRequestrequest){Stringreal=request.getHeader("X-Real-IP");if(StringUtils.hasText(real)){returnreal.trim();}Stringxff=request.getHeader("X-Forwarded-For");if(StringUtils.hasText(xff)){returnxff.split(",")[0].trim();}returnrequest.getRemoteAddr();}4.3 fail-open / fail-closed 矩阵
同一批代码里,我并存了两种失效哲学。裁决时只问一个问题:"它坏了会怎样?"逐个想过去,得到这张矩阵:
| 组件 | 失效时 | 理由 |
|---|---|---|
| 配额计数(Redis 抖动) | fail-open放行 | 闸门坏 ≠ 拒服务,主站可用性优先 |
| 口令未配置 | fail-closed全拒 | 静默放行 = 裸奔上线,必须暴露配置遗漏 |
| 业务前置闸(订单查不到) | fail-closed拒批 | 宁可 409 让人修数据,不可乱批退款 |
| Nacos 读不到闸口配置 | 降级本地兜底值 | 配置中心挂了不背闸门的锅 |
这张表看着"不一致",其实是同一套逻辑在起作用:配额计数 fail-open,因为闸门坏 ≠ 拒服务,Redis 抖一下就把所有访客关在门外,等于用闸门自己的事故去惩罚主站的无辜用户;口令未配置 fail-closed,因为静默放行等于裸奔上线,配置遗漏必须被暴露而不是被掩盖;业务前置闸 fail-closed,因为宁可 409 让人修数据,不可乱批退款;Nacos 读不到配置就降级本地兜底值,配置中心挂了不背闸门的锅。
配额存储我做成 seam:REDIS_ENABLED=true时走 RedisINCR+ 首写 48h TTL(重启不丢、多请求共享);否则内存 Map(重启清零,dev 可接受,4096 键剪枝防膨胀)。口令比对用MessageDigest.isEqual常量时间比较——这类便宜的标准件没必要自己写循环。
// gate/AccessGateService.java —— 同一批代码里两种失效哲学并存/** 原子计数尝试一次对话:额度内→true(计数+1);超限→false。存储故障→true(fail-open,不打死入口)。 */publicbooleantryAcquire(Stringip,intlimit){try{returnquotaStore.incrementAndGet(key(ip))<=limit;}catch(Exceptione){log.warn("闸口配额计数失败(fail-open 直通):ip={} reason={}",ip,e.getMessage());returntrue;}}/** 口令比对(常量时间;期望口令未配置一律不匹配——fail-closed 暴露配置遗漏)。 */publicstaticbooleancodeMatches(Stringgiven,Stringexpected){if(given==null||expected==null||expected.isBlank()){returnfalse;}returnMessageDigest.isEqual(given.getBytes(StandardCharsets.UTF_8),expected.getBytes(StandardCharsets.UTF_8));}4.4 eval 封锁的位置
/eval/**的 IP 检查我放在了token 鉴权之前:外部 IP 连"试 token"的机会都没有(401 文案直接说明仅限本机),本机请求照旧走 token 鉴权。这也是"统一旋钮"语义的一部分——闸口关(dev)时 eval 同步放开,不存在"闸口关了 eval 还锁着"的组合态。
5. 评测与前端:收口
评测异步化:全量黄金集逐例真 LLM,曾把 HTTP 请求拖爆且看不到进度——我改成POST /eval/run秒回进度快照、GET /eval/progress每秒轮询亮灯;黄金集本体迁到 Nacos(agentdemo-eval-<stage>.json),每次 run 实时拉取、失败回退本地 classpath。
前端三件:
- 可观测页图表深底浅字的可读性修复(只改字体色,不动配色体系);
- 会话页常驻显示sessionId(管理台按它查历史,但页面此前只显示每轮都变的 traceId,对不上号——查不到的记录等于没有记录);
/gate登录页 + 会话页闸口状态 chip(此前登录页只能靠 401 被动跳转触达,属于"功能存在但入口不存在")。
三件全部只用现有设计 token,没有新造色值。
6. 验证
测试怎么钉死这批改动——
后端mvn test:1157 通过 / 0 失败 / 11 门控跳过(跳过项均为需真实密钥的真库烟测)。本批新增覆盖:仲裁器 7 例、业务前置闸 14 例、检查点 Redis/严格键 5 例、闸口过滤器 2 例(loopback 豁免 + XFF 伪造刷额度——4.2 推演出来的那个洞,从此有测试看着)、eval 外部封锁 3 例、routePlan 收敛 6 例、工单 DB 回源 6 例。前端vue-tsc干净 +vitest83 通过。
真库实测把第 1 节的链路走了一遍:RAG 双通道真库检索,稠密 top1 cosine 0.60 过闸、BM25 top1 正中 FAQ 7.05 分;Lucene 开机同步 155 块、0 清理;"旧退款政策"问题的历史片段被隔离标注;无关问题 RAG_SKIP 兜底。
7. 已知限制(诚实清单)
- 单实例口径:售后提交登记、工单状态机、检查点内存真相,均为单实例语义;多实例需 DB 乐观锁 CAS(检查点消费处已留 CAS 语义,挪位置即可)。
- 长期记忆缺席:短期记忆(会话历史 + 摘要锚点)已覆盖理解层与决策层;跨会话的用户级长期记忆是明确的未做项(
chat_turn是存储不是记忆)。 - localhost 豁免的信任链:依赖 nginx 覆写 X-Real-IP + 8080 不对外。绕过 nginx 直连 8080 的请求不受闸门保护,靠安全组兜底。
- 密钥轮换:开发期用过的临时密钥(对话日志出现过前缀)仍在轮换清单上,公开部署前必须完成。
结语
这批改动没有引入新框架、新中间件——Lucene 是唯一的例外,而它是被证伪逼出来的。回头看,新增的每个组件都在回答同一个问题:**这条链路失败时,回退到哪?**仲裁器失败回退确定性状态机,Redis 副本失败回退 DB,配额存储失败 fail-open,业务对账失败 fail-closed。回退路径想清楚了,升级层才敢往上叠。
本文机制出处:capability/workflow/WorkflowTurnArbiter(会话仲裁器)、WorkflowSubmissionRegistry(提交业务记忆)、AfterSaleWorkflowGraph.extractOrderIdFrom(订单号提取);capability/hitl/HitlCheckpointService(严格锚点恢复)、HitlBusinessGate(业务前置对账)、hitl-checkpoint-writer/hitl-ticket-writer(异步双写)与src/main/resources/sql/hitl_l2_init.sql;gate/AccessGateService(IP 解析 / 配额 / 口令比对);配置出处 Nacosagentdemo-gate.json、agentdemo-eval-<stage>.json与 RAG 终闸RAG_MIN_SCORE。
相关阅读:全链路延迟与稳定性调优(80.5s → 6.1s) · 语料清洗切分入库教程 · 部署指南(含闸口 Nacos 配置)