从能跑到敢公开一个客服 Agent 的决策层、持久化与闸门硬化实录2026-09-18 · Agentdemo007 系列第六章系列目录上一篇《检索侧演进》此时延迟已从 80.5s 压到 6.1~11.9s调优实录见第八章。本篇记录的是把它变成敢挂到公网上当作品集的那批工作混合检索真 RAG 收尾、会话决策层升级方案A 仲裁器、HITL 挂起-恢复 L2 持久化、统一访问闸口。四块工作共享同一条设计纪律不推翻已经做好的东西——每一层都是既有链路之上的升级层失败时原样回退。源码github.com/Gavincui123/Agentdemo0070. 为什么要做这一批写到这一篇的时候项目已经能跑了多阶段流水线、主备容灾、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 dataIdmin-score: 0.4配置键RAG_MIN_SCORE——按部署约定调参键不放 envdev 词袋库默认 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 是本轮能力决策的唯一出处下游只执行不猜。收敛层在意图进决策层前修正候选值售后轮一律needsRagfalse政策语义走工作流内单通道查询澄清/衔接/确认这些终态从不消费主线 RAG无订单号则needsBusinessToolsfalse。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(replynull||reply.isBlank()){returnnull;}try{intstartreply.indexOf({);intendreply.lastIndexOf(});if(start0||endstart){returnnull;}JsonNodenodemapper.readTree(reply.substring(start,end1));Stringdecisionnode.path(decision).asText().trim().toUpperCase(Locale.ROOT);if(!DECISIONS.contains(decision)){returnnull;}Stringintentnode.path(intent).asText(null);if(BIND_RUN.equals(decision)(intentnull||!AFTER_SALE_INTENTS.contains(intent))){returnnull;}Stringreasonnode.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 检查点publicOptionalHitlCheckpointSnapshotfindActiveForTicket(StringticketId,StringexpectedKey){OptionalHitlCheckpointSnapshotfoundfindActive(ticketId);if(found.isEmpty()){returnOptional.empty();}if(expectedKeynull||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.jsonNacos 热更新改完秒级生效不重启{enabled:true,accessCode:你的访问口令,dailyLimit:5,requiredMessage:本站为作品集演示站点请输入访问口令见简历附注,exhaustedMessage:今日体验额度已用完每 IP 每日 5 轮欢迎明日再访}enabledtrue一次性生效三件事①/chat系列要求访问口令前端/gate登录页 会话页常驻入口 chip② 外部 IP 按自然日Asia/Shanghai计数限额localhost 豁免——本机联调不该被自己的闸门拦住③/eval/**仅限本机评测会跑真实 LLM 全量黄金集比聊天更烧钱必须比聊天更严。enabledfalse全部放开——dev 零门槛。口令只放 Nacos不进仓库、不进环境变量明文。统一旋钮是刻意的设计口令和限额若各配各的迟早出现口令开了、限额没开这类组合态而每种组合态都是一笔新的排查成本。4.2 一次推演出来的洞IP 伪造刷额度旋钮装好之后我换了假设我是攻击者的视角把请求流推演了一遍推出一个洞。配额键是客户端 IP而最初的解析顺序是 X-Forwarded-For 优先。推演一步就撞上了XFF 是请求头客户端想写什么写什么——每个请求换一个假 XFF就是无限份新鲜额度闸门形同虚设。这类洞不会在正常功能测试里现形只有把请求头当攻击面看才看得见。修复后顺序X-Real-IPnginxproxy_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客户端 IPX-Real-IPnginx 覆写可信// → X-Forwarded-For 第一跳 → remoteAddr。2026-09-18 顺序修正原 XFF 优先可被客户端伪造publicstaticStringclientIp(HttpServletRequestrequest){Stringrealrequest.getHeader(X-Real-IP);if(StringUtils.hasText(real)){returnreal.trim();}Stringxffrequest.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 读不到配置就降级本地兜底值配置中心挂了不背闸门的锅。配额存储我做成 seamREDIS_ENABLEDtrue时走 RedisINCR 首写 48h TTL重启不丢、多请求共享否则内存 Map重启清零dev 可接受4096 键剪枝防膨胀。口令比对用MessageDigest.isEqual常量时间比较——这类便宜的标准件没必要自己写循环。// gate/AccessGateService.java —— 同一批代码里两种失效哲学并存/** 原子计数尝试一次对话额度内→true计数1超限→false。存储故障→truefail-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(givennull||expectednull||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每秒轮询亮灯黄金集本体迁到 Nacosagentdemo-eval-stage.json每次 run 实时拉取、失败回退本地 classpath。前端三件可观测页图表深底浅字的可读性修复只改字体色不动配色体系会话页常驻显示sessionId管理台按它查历史但页面此前只显示每轮都变的 traceId对不上号——查不到的记录等于没有记录/gate登录页 会话页闸口状态 chip此前登录页只能靠 401 被动跳转触达属于功能存在但入口不存在。三件全部只用现有设计 token没有新造色值。6. 验证测试怎么钉死这批改动——后端mvn test1157 通过 / 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.sqlgate/AccessGateServiceIP 解析 / 配额 / 口令比对配置出处 Nacosagentdemo-gate.json、agentdemo-eval-stage.json与 RAG 终闸RAG_MIN_SCORE。相关阅读全链路延迟与稳定性调优80.5s → 6.1s · 语料清洗切分入库教程 · 部署指南含闸口 Nacos 配置