- 类别
- AI 与 MCP
- 平台
- Java · Spring Boot · 私有
- 许可
- 闭源
给二手交易平台卖家账号用的客服 Agent。LLM 负责起草回复,流程、红线和路由全由代码掌控。一个小型判定模型 Jev 给每条进来的消息打出校准过的概率做分诊,它本身不掌握任何控制权。
在浏览器里试试
这是闭源的个人项目,没有代码仓库,也不提供下载,这个页面只展示架构。下面的买家消息、商品和回复都是虚构的。预设场景的判定层展示的是一次真实录制(2026-10-09,jev-1.13.0);判定层之后的部分都在你的浏览器里模拟。除非你填入自己的 Jev key,否则不会向任何地方发送数据。
Jev:每条消息两次调用,录制时每次 773–1318 ms。只有注入那次调用决定路由;诈骗那次并行地以影子方式运行。
用你自己的 Jev key 实时跑
key 只会随你自己的请求,经本站 /api/jev 的转发通道发往 api.typesafe.ai。转发通道不存储任何东西,也不记录请求。不勾选就不会保存;勾选后也只存在这个标签页的 sessionStorage 里。点"清除"或关掉标签页就没了。
没有 key:预设展示录制数据,自由输入用玩具替身。没有任何数据离开页面。
自己用 curl 跑
当前这条消息的两次调用,和流水线实际构造的一模一样。命令里用的是字面量 $TYPESAFE_API_KEY 占位,绝不会把你输入的 key 放进去。把响应粘贴到下面,会走同样的展示和路由。
150 出吗?
- 成色
- 所有按键正常,手感均匀;用了一年左右。
- 瑕疵
- 空格和 WASD 键帽有轻微打油;外壳背面有一道小划痕。
- 配件
- 可拆卸 USB-C 线、拔键器。
- 不含
- 原包装盒。
知识卡没校对就不能开 AI:这种商品只会发一句"稍等",并提醒卖家。
floor_price = ¥ • • • 只存在数据库里,从不进提示词。 刚刚被代码读取。模型从没见过它。 - 1 MessageRouter按会话归属分发
- 2 原始归档 + 去重按天写 JSONL,在任何过滤之前
- 3 短路规则一律不调 LLM
- 4 判定层脱敏 → 关键词兜底 → 两个判定点
- 5 意图 → 处理器从这里开始是模拟的
- 6 LLM 起草只看得到该看的
- 7 出站守卫FactGuard · ReplyGuard · BargainGuard
- 8 发送,或暂停 AI 交给卖家
整体结构
只有一个网关服务会和平台打交道,它是唯一的出口,其他组件都只和网关通信。 Agent 服务(Java、Spring Boot)掌控整个流程。一个只读的 MCP 服务让助手可以查看数据,卖家另有一个管理后台。
每一条进出的事件都会在任何过滤之前先追加进按天切分的 JSONL 归档,所以什么都不会丢,历史也能回放。 消息按平台的消息 id 去重,因为以前每次重启都会把旧消息以"当前时间"重新插一遍。
方向相反的两个 Agent
两个 Agent 共用一个聊天通道,但朝向正好相反,形态也完全不同。
卖家 Agent
买家来问我的商品。事件驱动、基本是单轮对话,所以刻意不做成状态机: "一个干净的聊天客户端 + 几个拦截器 + 一道人工把关的工具闸门"。
BuyerScout 第二阶段
我去问别的卖家他们的商品。周期长,所以做成数据库驱动的状态机:表里的那一行就是检查点, 重启后从表里接着跑。默认每次发送都要人工批准。
前面有一个 MessageRouter,按会话归属分发消息。没有它的话,卖家人设会自动去回复那些卖家正在询价的对象。 恢复是事件驱动的:对方卖家几个小时后才回复,路由找到对应的询价,加载状态,把回复注入进去,然后继续:要么追问,要么下结论。
最高原则:两个通道
"LLM 看得到的"和"运行时看得到的"是两个独立的通道,两个 Agent 都遵守这一条。工具参数分成两类: 由模型决定的参数,写在 schema 里;由宿主注入的参数,模型从头到尾看不到,也就无从编造。
LLM 看得到
由模型决定的参数,写在工具 schema 里。比如议价处理器里的 reply 和 offered_price。
再加上写回复要用的上下文:商品文案、知识卡、买家消息。
只有运行时看得到
由宿主在带外注入:底价、账号身份、会话 id、发货凭据。
代码拿 offered_price 去和一个模型从没见过的底价做比较。
每加一个工具之前先问一句:这个参数该让模型看到吗?不该,就走带外注入。 判定层对自己的输入也是同样的思路:可信的 facts 和不可信的买家消息分开放。
判定层
Jev 是 TypeSafe 的 "System One" 模型。它针对类型化的问题返回校准过的概率:noul(是/否的概率)、
choice(多选一)、score(按一组标准打分),不生成任何文本。实测下来,它识别诈骗、意图和可信度很强,
对注入几乎免疫;但它不会算术,也不知道近期的事。所以它只负责分诊:从不下最终结论,从不碰价格,也不给回复质量打分。
入站这一侧有两个判定点,各自是一次独立的 Jev 调用:
inbound.injection:线上,在热路径上。只问一个问题injection(noul)。 命中注入关键词兜底,或者概率 ≥ 0.85,就路由到 QUARANTINE;其余都是 PASS。阈值取 0.85,是因为攻击样本实测 0.97、 正常消息 0.03,而同一个请求会有 ±0.03 左右的抖动。inbound.scam:只跑影子。问两个问题:scam(noul)和intent(choice)。 线上只由它的关键词兜底决定路由:命中就是 HUMAN,否则 PASS。模型的答案来自独立线程池上的异步调用,只记日志, 用来积累带标注的数据,直到通过第 4 条决定里的门槛。
路由是一个真实的枚举。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 都是顺带白送的。
九个关键决定
每一条都争论过,下面是结论。
-
双通道状态,用类型强制
早期研究里,同一句伪造的"该买家已认证",放进 facts 字段时,诈骗概率从 0.60 掉到 0.29,诈骗被放了过去;放在买家消息里则被当成可疑信号(0.70 → 0.73)。所以 facts 必须是
Trusted,它只有db()和computed()两个工厂方法。故意不提供fromBuyer():把买家原话塞进 facts 根本编译不过。本页的录制数据以较小的幅度复现了这个效应。 -
判定 ≠ 控制层,这是结构上的事实
RoutingPolicy只返回一个枚举。判定包不允许依赖任何发送或持久化组件,由 CI 里一条扫描源码的测试来保证(ArchUnit 风格)。 -
降级要安全
热路径 = Jev(有限次)→ 关键词兜底(即时、不走网络),整体受一个硬性总时限约束。引擎故障 = PASS,降级为只靠关键词兜底。绝不在消息线程上串行退回 LLM 判定,否则恰好在 Jev 不稳的时候把主 LLM 栈的负载顶上去。也不做"Jev 挂了 → 全部交给卖家",那样会把队列淹掉。精度可以降,服务不能停。
-
两条独立的生命周期
注入检测很强(攻击样本 0.97,正常消息 0.03,零误报),所以先上线:按 5% → 25% → 100% 放量,每档至少 3 天,误报 < 1% 且零漏报才进下一档。诈骗检测证据单薄(12 个样本,中文"软性诈骗"是盲区),所以只跑影子:异步执行,配人工标注落日志,线上路由一律不用,直到过了这些门槛:正样本 ≥ 30 且每个概率分桶 ≥ 10、阈值 0.5 时精确率 ≥ 0.9、配合关键词兜底中文召回 100%、ECE < 0.1、连续两周回归无漂移。过不了就永远留在影子模式。
-
盲区交给关键词兜底
中文的"先发货、后付款"和威胁话术是 Jev 唯一漏掉的。这类消息用确定性的关键词 → HUMAN 来处理,而不是去调低 Jev 的阈值,那样会让正常砍价的误报变多。兜底规则是为中文买家写的,所以英文的"先发货"消息今天在线上会放行;Jev 会给它打分,但只进影子日志。
-
放弃 trust 分
测试中 Jev 的 trust 分和风险反着走。买家信誉改由代码根据卖家自己的订单历史(退款率、未付款订单、黑名单)计算,作为
Trustedfact 传进去。这条路是通的:一段糟糕的历史把一条中性消息的诈骗分从 0.06 推到了 0.38。 -
缓存与幂等
缓存键 = sha256(provider + model + questionsVersion + 完整 state 的规范化 JSON)。按 (conversationId, messageId) 去重,因为断线重连会重放消息。买家消息为空就短路;facts 为空则不短路,因为新买家的第一条消息也必须过判定。
-
在唯一出口处脱敏
要出境的数据在出口处脱敏:身份证号、手机号、微信号、QQ 号和详细地址都换成
[微信号]这样的占位符。占位符保留了信号,去掉了标识本身。只发当前这条消息和最少的上下文,从不发完整历史。 -
先盘点能力,再写代码
不另设第二道风控闸门。
SideEffectPolicy、FactGuard、ReplyGuard、BargainGuard、挽留 / 问候 / 识图这几条短路,以及人工接管暂停,逐个读过后全部保留;判定层直接复用这个暂停,不另造机制。唯一缺的能力是:入站方向完全没有提示词注入防御。判定层的第一件事就是补上这个缺口,所以先上线的正是inbound.injection。
按实际情况设计的记忆
二手买家基本是一次性访客:问一两句,买了就走。所以这个设计删掉了"企业客服"模板里通常会有的东西:
没有买家画像表,只有轻量的 buyer_flags(退款记录、黑名单);没有每晚的反思任务,改成在成交事件上提炼,
外加一个"一键存为 FAQ"按钮;FAQ 也只给有多件库存的商品用。
- L0进程内
- L1聊天消息
- L2知识卡 + FAQ + flags
- 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 个全绿 |
当前状态
inbound.injection:已上线,带一键关闭开关。inbound.scam:只跑影子;线上只由关键词兜底路由,直到通过第 4 条决定里的门槛。- 已知缺口(上面原样展示了):关键词兜底只覆盖中文,英文的引导站外和先发货消息在线上会放行。
- 线上用哪个引擎(Jev 还是 LLM 判定):根据这些实测结果重新评估中。
- BuyerScout:第二阶段的 Agent,默认每次发送都要人工批准。
- 闭源。没有代码仓库,不提供下载。