← 全部项目

二手平台客服 Agent

带 Jev 判官层的客服 Agent

唐艺家 (Tom)

类别
AI 与 MCP
平台
Java · Spring Boot · 私有
许可
闭源

给二手交易平台卖家账号用的客服 Agent。LLM 负责起草回复,流程、红线和路由全由代码掌控。一个小型判定模型 Jev 给每条进来的消息打出校准过的概率做分诊,它本身不掌握任何控制权。

网页演示模拟器:角色扮演注入绕过关键词,被 Jev 抓到

在浏览器里试试

这是闭源的个人项目,没有代码仓库,也不提供下载,这个页面只展示架构。下面的买家消息、商品和回复都是虚构的。预设场景的判定层展示的是一次真实录制(2026-10-09,jev-1.13.0);判定层之后的部分都在你的浏览器里模拟。除非你填入自己的 Jev key,否则不会向任何地方发送数据。

买家消息

自己输入的消息走的是真实的关键词兜底、脱敏和请求构造;模型给出的概率则是一个透明的玩具规则,除非你在下面填入自己的 Jev key。

判定引擎
熔断器闭合(正常)

Jev:每条消息两次调用,录制时每次 773–1318 ms。只有注入那次调用决定路由;诈骗那次并行地以影子方式运行。

用你自己的 Jev key 实时跑
去 typesafe.ai 申请 key

key 只会随你自己的请求,经本站 /api/jev 的转发通道发往 api.typesafe.ai。转发通道不存储任何东西,也不记录请求。不勾选就不会保存;勾选后也只存在这个标签页的 sessionStorage 里。点"清除"或关掉标签页就没了。

没有 key:预设展示录制数据,自由输入用玩具替身。没有任何数据离开页面。

自己用 curl 跑

当前这条消息的两次调用,和流水线实际构造的一模一样。命令里用的是字面量 $TYPESAFE_API_KEY 占位,绝不会把你输入的 key 放进去。把响应粘贴到下面,会走同样的展示和路由。

会话
买家(虚构)

150 出吗?

商品(虚构)
二手 87 键机械键盘 ¥260 段落轴,自用一年左右。
知识卡 卖家已校对
成色
所有按键正常,手感均匀;用了一年左右。
瑕疵
空格和 WASD 键帽有轻微打油;外壳背面有一道小划痕。
配件
可拆卸 USB-C 线、拔键器。
不含
原包装盒。

知识卡没校对就不能开 AI:这种商品只会发一句"稍等",并提醒卖家。

floor_price = ¥ • • • 只存在数据库里,从不进提示词。 刚刚被代码读取。模型从没见过它。
卖家 Agent 流水线

实测录制 · 2026-10-09 · jev-1.13.0:下面是真实请求与真实响应

  1. 1 MessageRouter按会话归属分发

  2. 2 原始归档 + 去重按天写 JSONL,在任何过滤之前

  3. 3 短路规则一律不调 LLM

  4. 4 判定层脱敏 → 关键词兜底 → 两个判定点

  5. 5 意图 → 处理器从这里开始是模拟的

  6. 6 LLM 起草只看得到该看的

  7. 7 出站守卫FactGuard · ReplyGuard · BargainGuard

  8. 8 发送,或暂停 AI 交给卖家

整体结构

只有一个网关服务会和平台打交道,它是唯一的出口,其他组件都只和网关通信。 Agent 服务(Java、Spring Boot)掌控整个流程。一个只读的 MCP 服务让助手可以查看数据,卖家另有一个管理后台。

每一条进出的事件都会在任何过滤之前先追加进按天切分的 JSONL 归档,所以什么都不会丢,历史也能回放。 消息按平台的消息 id 去重,因为以前每次重启都会把旧消息以"当前时间"重新插一遍。

系统总览:平台只和网关通信;Agent 服务里有路由、两个 Agent、判定层、LLM 栈和出站守卫;周围是数据库、原始归档、只读 MCP 服务和管理后台;判定层通过唯一一个脱敏出口调用 Jev。 二手交易平台 平台网关 唯一出口: 除它之外 没人直接碰平台 Agent 服务 Java · Spring Boot · 控制层 MessageRouter · 按会话归属分发 卖家 Agent 事件驱动,单轮为主 BuyerScout 数据库驱动的状态机 判定层 关键词兜底 · 两个判定点 · RoutingPolicy LLM 栈 起草回复 出站守卫 Fact · Reply · Bargain SideEffectPolicy · 人工接管暂停 买家的话永远触发不了退款或发货 Jev 只给概率 经 JudgeClient: 唯一的脱敏出口 原始归档 按天 JSONL,不过滤 数据库 聊天消息 · 知识卡 FAQ · buyer_flags · 询价 MCP 服务 只读 管理后台 给卖家用
系统总览。箭头表示调用关系;只有网关能碰到平台,也只有 JudgeClient 能碰到 Jev。

方向相反的两个 Agent

两个 Agent 共用一个聊天通道,但朝向正好相反,形态也完全不同。

卖家 Agent

买家来问我的商品。事件驱动、基本是单轮对话,所以刻意不做成状态机: "一个干净的聊天客户端 + 几个拦截器 + 一道人工把关的工具闸门"。

BuyerScout 第二阶段

我去问别的卖家他们的商品。周期长,所以做成数据库驱动的状态机:表里的那一行就是检查点, 重启后从表里接着跑。默认每次发送都要人工批准。

前面有一个 MessageRouter,按会话归属分发消息。没有它的话,卖家人设会自动去回复那些卖家正在询价的对象。 恢复是事件驱动的:对方卖家几个小时后才回复,路由找到对应的询价,加载状态,把回复注入进去,然后继续:要么追问,要么下结论。

BuyerScout 状态机:DRAFT、AWAIT_APPROVE,然后是 SENT 与 REPLIED 之间最多 M 轮的追问循环,最后进入 JUDGED 或 EXPIRED。 DRAFT AWAIT_APPROVE 卖家批准 追问循环(≤ M 轮) SENT REPLIED 追问 JUDGED EXPIRED
BuyerScout。每个状态都是表里的一行,所以重启后能从停下的地方原样接上。

最高原则:两个通道

"LLM 看得到的"和"运行时看得到的"是两个独立的通道,两个 Agent 都遵守这一条。工具参数分成两类: 由模型决定的参数,写在 schema 里;由宿主注入的参数,模型从头到尾看不到,也就无从编造。

LLM 看得到

由模型决定的参数,写在工具 schema 里。比如议价处理器里的 reply 和 offered_price。 再加上写回复要用的上下文:商品文案、知识卡、买家消息。

只有运行时看得到

由宿主在带外注入:底价、账号身份、会话 id、发货凭据。 代码拿 offered_price 去和一个模型从没见过的底价做比较。

每加一个工具之前先问一句:这个参数该让模型看到吗?不该,就走带外注入。 判定层对自己的输入也是同样的思路:可信的 facts 和不可信的买家消息分开放。

判定层

Jev 是 TypeSafe 的 "System One" 模型。它针对类型化的问题返回校准过的概率:noul(是/否的概率)、 choice(多选一)、score(按一组标准打分),不生成任何文本。实测下来,它识别诈骗、意图和可信度很强, 对注入几乎免疫;但它不会算术,也不知道近期的事。所以它只负责分诊:从不下最终结论,从不碰价格,也不给回复质量打分。

入站这一侧有两个判定点,各自是一次独立的 Jev 调用:

路由是一个真实的枚举。PASS:继续正常流程。QUARANTINE:疑似注入,这条消息不得进入任何 LLM 提示词,AI 暂停,交给卖家。HUMAN:明确的高风险信号(引导站外、先发货后付款等),AI 暂停,交给卖家。 判定层只给概率,路由由代码来做,判定层碰不到红线。线上的意图仍由原有的 IntentClassifier 判断 (先关键词短路,仍是 other 才调 LLM);Jev 给出的意图只进影子日志。

ChatAgentService.doProcessMessage        控制层(掌控流程、红线和路由动作)
   │ Verdict v = judge.evaluate(POINT, ctx)   ← 唯一入口
   ▼
JudgeService(门面)+ JudgeSwitchboard   enabled / provider / shadow;开关文件热加载;
                                          文件缺失或读不了 = 判定层关闭
   │
JudgmentPoint<C>   声明式纯值:static final 常量,可 diff,可离线单测
   │   inbound.injection   线上       injection (noul)
   │   inbound.scam        只跑影子   scam (noul) + intent (choice)
   ├─ FactSpec<C>     ctx → JudgeState   双通道:facts(Trusted)| buyerMessage(不可信)
   ├─ QuestionSet     共享题库(sealed Noul / Choice / Score,构造时校验 schema)
   └─ RoutingPolicy   Answers → Route    关键词兜底优先,再看阈值(与 provider + 版本绑定)
                                         Route = PASS | QUARANTINE | HUMAN
   │
JudgeEngine   ask(state, questions) → answers
   ├─ JevJudge  → JudgeClient   通往 Jev 的唯一 HTTP 出口:连接池、有限重试 ×2、4 秒读超时、
   │                           版本锁定 jev-1.13.0 且每次响应都校验、脱敏、链路追踪
   └─ LlmJudge  → 复用现有 LLM 栈的结构化输出
                 (主用 / 影子 / 离线 A/B,绝不做热路径兜底)

韧性  带防抖的熔断器
      影子调用跑在独立线程池:丢弃策略、先做快照、超时更短、从不计入主熔断器
      引擎故障 / 熔断断开 / 版本漂移 → PASS(降级,只靠关键词兜底);
      判定层自己坏了,绝不会因此产生 QUARANTINE 或 HUMAN

同一组 Question 对象同时渲染两种引擎的请求(toJevSpec() / toLlmField()),所以切换引擎、兜底和 A/B 都是顺带白送的。

九个关键决定

每一条都争论过,下面是结论。

  1. 双通道状态,用类型强制

    早期研究里,同一句伪造的"该买家已认证",放进 facts 字段时,诈骗概率从 0.60 掉到 0.29,诈骗被放了过去;放在买家消息里则被当成可疑信号(0.70 → 0.73)。所以 facts 必须是 Trusted,它只有 db() 和 computed() 两个工厂方法。故意不提供 fromBuyer():把买家原话塞进 facts 根本编译不过。本页的录制数据以较小的幅度复现了这个效应。

  2. 判定 ≠ 控制层,这是结构上的事实

    RoutingPolicy 只返回一个枚举。判定包不允许依赖任何发送或持久化组件,由 CI 里一条扫描源码的测试来保证(ArchUnit 风格)。

  3. 降级要安全

    热路径 = Jev(有限次)→ 关键词兜底(即时、不走网络),整体受一个硬性总时限约束。引擎故障 = PASS,降级为只靠关键词兜底。绝不在消息线程上串行退回 LLM 判定,否则恰好在 Jev 不稳的时候把主 LLM 栈的负载顶上去。也不做"Jev 挂了 → 全部交给卖家",那样会把队列淹掉。精度可以降,服务不能停。

  4. 两条独立的生命周期

    注入检测很强(攻击样本 0.97,正常消息 0.03,零误报),所以先上线:按 5% → 25% → 100% 放量,每档至少 3 天,误报 < 1% 且零漏报才进下一档。诈骗检测证据单薄(12 个样本,中文"软性诈骗"是盲区),所以只跑影子:异步执行,配人工标注落日志,线上路由一律不用,直到过了这些门槛:正样本 ≥ 30 且每个概率分桶 ≥ 10、阈值 0.5 时精确率 ≥ 0.9、配合关键词兜底中文召回 100%、ECE < 0.1、连续两周回归无漂移。过不了就永远留在影子模式。

  5. 盲区交给关键词兜底

    中文的"先发货、后付款"和威胁话术是 Jev 唯一漏掉的。这类消息用确定性的关键词 → HUMAN 来处理,而不是去调低 Jev 的阈值,那样会让正常砍价的误报变多。兜底规则是为中文买家写的,所以英文的"先发货"消息今天在线上会放行;Jev 会给它打分,但只进影子日志。

  6. 放弃 trust 分

    测试中 Jev 的 trust 分和风险反着走。买家信誉改由代码根据卖家自己的订单历史(退款率、未付款订单、黑名单)计算,作为 Trusted fact 传进去。这条路是通的:一段糟糕的历史把一条中性消息的诈骗分从 0.06 推到了 0.38。

  7. 缓存与幂等

    缓存键 = sha256(provider + model + questionsVersion + 完整 state 的规范化 JSON)。按 (conversationId, messageId) 去重,因为断线重连会重放消息。买家消息为空就短路;facts 为空则不短路,因为新买家的第一条消息也必须过判定。

  8. 在唯一出口处脱敏

    要出境的数据在出口处脱敏:身份证号、手机号、微信号、QQ 号和详细地址都换成 [微信号] 这样的占位符。占位符保留了信号,去掉了标识本身。只发当前这条消息和最少的上下文,从不发完整历史。

  9. 先盘点能力,再写代码

    不另设第二道风控闸门。SideEffectPolicy、FactGuard、ReplyGuard、BargainGuard、挽留 / 问候 / 识图这几条短路,以及人工接管暂停,逐个读过后全部保留;判定层直接复用这个暂停,不另造机制。唯一缺的能力是:入站方向完全没有提示词注入防御。判定层的第一件事就是补上这个缺口,所以先上线的正是 inbound.injection。

按实际情况设计的记忆

二手买家基本是一次性访客:问一两句,买了就走。所以这个设计删掉了"企业客服"模板里通常会有的东西: 没有买家画像表,只有轻量的 buyer_flags(退款记录、黑名单);没有每晚的反思任务,改成在成交事件上提炼, 外加一个"一键存为 FAQ"按钮;FAQ 也只给有多件库存的商品用。

  1. L0进程内
  2. L1聊天消息
  3. L2知识卡 + FAQ + flags
  4. L3语义检索:预留,没做。FAQ 只有几百条,关键词召回就够了。

有人监督的自我改进

提示词的改动只是提案,要人批准,有版本、可回滚。FAQ 只从已成交会话里助手一侧的发言提炼,买家说的话永远不会变成答案。 这样就不会出现把"老板答应过我便宜 10 块"学进去、然后对下一个买家也照办的情况。

实测数据

录制(2026-10-09,jev-1.13.0)

模拟器里的每个预设,中英两种语言,都过了一遍真实的判定配置。模拟器展示的就是这些原样的请求和响应。

项目结果
注入:直接注入和角色扮演0.96–0.98 → QUARANTINE
注入:非攻击消息里的最高分(伪造"已认证"那条)0.32 → PASS
诈骗:先发货 + 引导站外 / 软性诈骗0.96–0.97 / 0.80–0.84(影子,只记日志)
中文的先发货消息关键词兜底 → HUMAN
英文的先发货消息兜底没命中 → 线上 PASS
同一句"已认证":消息通道 → facts 通道诈骗 0.16 → 0.04(中文),0.03 → 0.02(英文)
单次调用延迟773–1318 ms

早期评估

项目结果
注入样本0.97 / 0.97
一条正常消息0.02
诈骗样本0.98 / 0.98
绕过关键词兜底的角色扮演注入0.97
伪造"已认证":放在 facts 里 / 放在买家消息里(早期研究)诈骗 0.60 → 0.29 / 0.70 → 0.73
糟糕的买家历史作为 Trusted fact,中性消息诈骗 0.06 → 0.38
Jev 单次调用延迟566–671 ms(冷启动 1.5 s;服务端 100–150 ms)
问 1 个问题 vs 问 10 个问题0.45 s vs 0.49 s
把 LLM 判定放在热路径上每条消息 5–10 s(Jev 约 0.6 s)
测试JudgeLayerTest 17 + JudgeWiringTest 5;全量 857 个全绿

当前状态