接入 APUS-OpenJev-v1 时,真正要先定下来的不是接口能不能调通,而是两件事:调用层怎么设超时和入口,消费层怎么对待返回内容。常见的坑是把模型输出的 decision 字段直接拿去执行——放行、拒绝、发通知,中间没有任何校验;一旦返回超时、JSON 被截断、字段类型变了,业务链路就跟着一起卡住或做出错误动作。比较稳妥的做法是让模型只产出建议和理由,由业务侧的规则层决定最终动作,再把超时、异常和降级写进调用封装里。
接入的稳定点不在模型侧,而在调用层和消费层。调用层用配置固定入口、连接超时和读取超时,把最坏耗时压在一次业务请求能接受的范围里;消费层先校验字段结构和类型,缺字段、类型不符、解析失败一律走兜底,不把模型输出当作可执行指令。这三件事都可以通过改配置、看日志、构造一次异常请求来验证,不需要额外的压测环境。
下面按“先约定形态、再谈消费”的顺序展开,每一节都给出可替换的配置、代码骨架或验证动作,不写死任何具体接口地址。
在配置里固定调用入口与超时时间
超时不要写在调用代码里,否则改一次要重新发版。通常放在配置中心或本地配置文件,敏感值(密钥、令牌)走环境变量注入。入口地址也只保留一个占位符,方便不同环境切换。
# 位置示例:application.yml / config.yaml / 配置中心的 openjev 分组
openjev:
client:
endpoint: ${OPENJEV_ENDPOINT} # 由环境变量注入,代码里不写死
connect-timeout-ms: 300 # 建连超时,通常小于读取超时
read-timeout-ms: 1500 # 单次读取超时
total-timeout-ms: 2000 # 整个调用的上限,包含重试
max-retries: 0 # 先关重试,避免超时被叠加
worker-pool-size: 8 # 并发调用线程数,按业务线程池余量给
几个判断:total-timeout-ms 应该小于上游业务接口的超时,否则模型侧还没返回,业务侧已经先超时了;max-retries 先设 0,确认链路上限之后再决定是否放开;并发线程数不要和业务主线程池共用,避免慢调用把业务线程占满。
验证方式:把 read-timeout-ms 临时改成 50,跑一次正常请求,观察调用是否按预期抛出超时异常、日志耗时是否接近 50ms 而不是一直挂着。也可以用一段脚本单独打调用封装,测完记得改回原值。具体参数名要结合项目使用的客户端库确认,不同库对“连接超时”和“读取超时”的命名不一致。
约定返回字段结构与缺失值处理
业务侧要能稳定解析结果,就得先约定一份最小字段清单,并且规定字段缺失时怎么走。下面是一份通用示例,字段名按实际情况替换:
{
"request_id": "req-0001",
"status": "ok",
"decision": "review",
"confidence": 0.62,
"reason": "命中规则 A,建议人工确认",
"model_version": "APUS-OpenJev-v1",
"latency_ms": 812
}
status:只认ok,其他值(含空值)直接走兜底。decision:限定在白名单枚举内,出现白名单之外的取值一律降级为review,不要当成 allow 执行。confidence:期望是数值。如果是字符串"0.62",可以尝试宽松转换;转换失败就当作缺失,不参与自动化判断。reason:缺失时填固定文案,比如no_reason,不要让下游拿到 null。latency_ms、model_version:只用于日志和排查,缺失不影响主流程。- 未知字段:忽略,不要因为多了字段就抛解析异常。
分支处理建议写成一段独立的校验函数,调用方只拿校验后的对象。校验顺序通常是:JSON 能否解析 → status 是否合法 → decision 是否在枚举内 → 数值字段类型是否符合。任何一步不通过就返回兜底对象,并带上失败原因,而不是抛出异常让上层处理。
写一段异常捕获覆盖超时与格式错误
封装入口要把超时、连接失败、解析失败都收在同一个位置,保证异常不会穿透到主流程。下面是通用骨架,占位符需要替换:OpenJevClient、DecisionParser、Decision 换成项目里实际的类名,TIMEOUT_MS 从配置读取,traceId 由上游请求头或网关传入,不要在这里新生成。
public Decision handle(Input input, String traceId) {
long start = System.currentTimeMillis();
String source = "model";
String errorType = "none";
Decision result;
try {
String body = openJevClient.invoke(input, TIMEOUT_MS); // 占位:替换为实际调用
result = parser.parse(body); // 占位:替换为实际解析
if (result == null || !result.isStructurallyValid()) {
source = "fallback";
errorType = "PARSE_INVALID";
result = Decision.fallback("PARSE_INVALID");
}
} catch (TimeoutException e) {
source = "fallback";
errorType = "TIMEOUT";
result = Decision.fallback("TIMEOUT");
} catch (Exception e) {
source = "fallback";
errorType = "CALL_FAILED";
result = Decision.fallback("CALL_FAILED");
} finally {
log.info("openjev_call trace_id={} source={} error_type={} latency_ms={}",
traceId, source, errorType, System.currentTimeMillis() - start);
}
return result;
}
日志字段至少保留四个:trace_id(串联上下游)、source(model 还是 fallback)、error_type(区分超时和格式问题)、latency_ms(判断耗时是否贴近超时上限)。fallback 里不要吞掉原始异常信息,但也不要整段打印返回体,避免把大段文本写进日志。
构造一次异常请求验证兜底是否生效
兜底路径不主动制造异常是验证不出来的。可以在测试环境用下面几种方式制造:
- 把配置里的
endpoint临时指向本地一个只接受连接、不返回内容的端口,触发读取超时。可以用nc起一个空响应端口,具体参数受版本影响,先在本机确认再加到脚本里。 - 让 stub 返回 200 但 body 为空,或者返回被截断的 JSON,用来验证解析失败分支。
- 返回合法 JSON 但删掉
decision字段,或把confidence改成字符串,验证缺失值和类型不符的处理。 - 直接让端口不监听,或者临时用出方向规则丢弃到目标端口的包,验证连接失败分支。用完记得删掉规则,别留在机器上。
观察点有四个:调用是否在 total-timeout-ms 附近返回,而不是一直等待;业务侧兜底动作是否真的执行了(比如进人工队列、返回默认决策);日志里是否出现 source=fallback 且 error_type 与构造的异常类型一致;主流程是否照常返回可识别的降级结果,而不是把异常抛给前端。
这几步建议在测试环境或用配置开关控制,不要直接在生产改入口地址。验证完把配置改回去,再跑一次正常请求确认 source=model。
在日志里留下决策来源标记
事后复盘时,最常见的困惑是“这条记录到底是谁给的结论”。在调用结束的日志里固定写上来源标记,问题就好定位了:
{"ts":"<ISO8601>","level":"INFO","event":"openjev_call",
"trace_id":"<trace_id>","request_id":"<request_id>",
"source":"model","decision":"review","error_type":"none",
"latency_ms":0,"fallback_action":"none"}
source:model表示结果来自模型,fallback表示走了兜底。fallback_action:兜底时具体做了什么,比如queue_review、return_default;正常时写none。error_type:与上一节的取值保持一致,便于按类型统计条数。
检索方式按日志落盘格式选:纯文本日志用 grep '"source":"fallback"' app.log,JSON 日志用 jq -c 'select(.event=="openjev_call" and .source=="fallback")' app.log。要追单次请求,用 trace_id 把网关日志、调用日志和业务日志串起来,就能看出这条决策是模型给的还是兜底给的、兜底时具体落了哪个动作。
需要留意的是,source 字段只在调用封装里写一次,不要在业务各处重复记录,否则同一个 trace 下会出现多条不一致的来源标记,反而增加排查成本。