设计层决定「做什么」,实现层决定「坑在哪」。
上一篇把设计层定了:闭集 + 概率、两段式采信、默认关闭 + 影子先行。本篇讲怎么把它写进代码,以及跟真实决策模型对接时撞上的四个坑——这四个坑都不在文档里,只能实测出来,而它们直接决定了阈值、费用口径和审计字段该长什么样。
先对齐一个数量级:判定一次外呼只有几百毫秒,而它省下的是一整轮秒级推理(这条账的完整算法在第四篇)。这笔账只有在判定靠谱时才成立,所以下面这四个坑值得一个一个抠。
一、一个端口 + 一个实现:上层不感知 Jev
判断这一步被切成「端口 + 实现」:
org.dromara.ai.core.route
├── RouteJudge // 端口:judge(请求) → 判定
├── JevRouteJudge // 实现:JevClient(协议原生,靠配置切厂商)
└── RouteJudgeRequest/Verdict // 入参:state + 候选;出参:choice + 概率 + 耗时 + token
上层 RouteJudgeService 只注入端口,不感知 Jev 的任何细节;门面注入这个 service,而编排链路不参与判断——判断永远是「额外一步」,不搅进编排。
这么切有三个直接好处:单测可以打桩(不需要真的外呼)、换模型换厂商不动上层、判定逻辑能独立演进而不碰编排。
实现类内部包掉四件事:构造请求、调用、校验、超时与异常归一化——异常一律转成「无判定」,绝不往上抛(第六节讲为什么)。
二、为什么不用框架自带的三个 JEV 中间件
AgentScope 2.0.4 的 agentscope-extensions-jev 模块只提供三个参考中间件,覆盖的是另外三类判断:
| 中间件 | 拦截点 | 判断对象 |
|---|---|---|
JevModelRouterMiddleware | onAgent / onModelCall | 选模型 |
JevToolSelectionMiddleware | onReasoning | 选工具 |
JevAutoModeMiddleware | onActing | 工具执行的安全性 |
没有「选专家」的中间件;而且我们的路由判断发生在编排层,不在小Z 的 Agent 回调里——中间件根本挂不上。所以这里不是重复造轮子,而是直接注入 JevClient、自建判断组件。
顺带两条版本事实(都实测过):在线文档里出现的 JevExecution.Mode.SHADOW/ENFORCE 执行模式、以及 team routing / phase routing,在 2.0.4 中不存在(本地全仓 grep 零命中)。所以「影子 / 接管」两种执行模式是我们自建的——这也解释了第一篇那套 shadow → gate 为什么是自己写的,而不是框架开关。
三、输入怎么拼:只放判断必需的事实
state 是决策模型的全部输入。原则是只放判断必需的东西,不喂完整对话历史——既是判断质量的要求(它不做长文理解),也是数据出境面的控制。
- 本轮用户原话:不拼历史全文;
- 候选清单:
agent_code+ 名称 + 描述(描述即「擅长」),与平台既有的候选清单同源,含可见范围过滤; - 会话槽位摘要:语言 / 时区 / 任务线,可为空,不求全。
两个实现细节值得单拎出来说:
- 候选清单永不截断。
state有长度上限(协议侧 65536 token,实现侧按字符数做前置保护),但曾经的截断是从头截——长原话会把候选清单整段切掉,等于把「选项」删了再让模型选。这个既有隐患必须一并修掉。 - 选项 key 用
agent_code,不用主键。 与下游expert_agent_key同口径,避免「判定回了一个雪花 ID、下游认不出来」。另外固定补两个选项:self(自答)与other(无法判断归属)——other是逃生口,不是错误码。
四、四个真实的坑
1. 概率和不保证为 1,而框架会因此直接抛异常
百炼返回的概率是两位小数,和可能是 0.99 / 1.01(稳定复现:{self:0, a:0.44, b:0.52, other:0.03} → 0.99)。而框架 JevClient 的校验容差只有 1e-6 × 选项数,会直接抛 JevException;更要命的是这个异常不带响应体(getResponseBody() 为 null),「捕获异常、自己兜底解析」这条路是被堵死的。
处置:复用协议 DTO,自建调用与宽松校验——容忍 ≤0.05 的概率和偏差并告警留痕,而不是丢掉一次判定。
2. 是非题(noul)不返回置信度
只有 choice / score 带 confidence。所以将来若要用「是否需要人工确认」这类是非题,不能依赖置信度阈值,只能自己按 P(yes) 定口径。
3. usage.outputTokens 恒为 0
百炼只返回 input_tokens;DTO 的 outputTokens 是基本类型,缺字段被填 0 而不是报错。费用口径只能按输入 token 计——拿 outputTokens 参与计算,结果一定是 0。
4. 判定有波动
同一输入两次调用,top 选项一致,但概率 0.93 → 0.86、置信度 0.90 → 0.81。结论很直接:阈值不能拍脑袋,必须用自有样本校准(第三篇讲怎么校准)。
五、每一次判定都要留痕
判断这一步最大的额外收益不是延迟,而是它变成了可分析的数据。所以每一次判定(含失败)都留痕:来源、选择、置信度、概率分布、耗时、错误码、执行模式。
错误码要能被人读懂,并且不静默:
margin_rejected:top1达阈值但 margin 不足——这正对应「勉强第一」型误接管的观察面;circuit_breaker_open:熔断期间未判定;- 超时 / 非 2xx / 越界各有对应码。
前端把这些码翻成中文文案、并落到「结构化决策判定」详情卡里;未知码原样透出——不认识的码也要看得见,它往往是新问题的第一个信号。
六、实现层的两条降级纪律
- 异常归一化为「无判定」:超时、非 2xx、解析失败、选项越界,全部转成「本轮没有判定」,由上层回落原有工具决策。绝不抛异常、绝不重试专家、绝不换人。
- 取数失败必须局部降级:比如拼会话上下文时查上一轮结果失败,应当是「不拼这个分区、继续判定」,而不是「整轮不判定」——一次 DB 抖动不该让判定能力整体失效。
七、小结
对接一个「协议同源」的模型,最贵的不是写调用代码,而是把它的怪癖当成事实来适配:概率和不为 1、是非题没有置信度、缺字段被静默填 0、同一输入会波动。这四条决定了你的阈值、费用口径和审计字段——先实测,再定参数。
下一篇讲怎么把这份能力放上去并且收得住:影子数据怎么变成阈值、超时与熔断怎么兜底、追问场景怎么做到根本不外呼,以及数据出境与权限边界这两条合规红线。
项目已在 Gitee 开源:gitee.com/zl3624/biz-…。欢迎来 Issue 交流。