写在前面
项目里凡是沾"AI"的功能——IM 里的 AI 助手、消息翻译、审批预审——背后都走同一个入口。这个入口的设计目标可以用一个问题概括:明天公司把大模型供应商从 A 换到 B,要改多少代码?
答案应该是一个配置文件的几行字,而不是各业务模块里的几十处调用。这篇记录这层"LLM 接入层"的设计:统一协议、错误分型、配置分层,以及每一步为什么这样划界。
一、先立契约:请求与响应的统一形状
接入层对上(业务模块)暴露两个极简的类型:
public record AgentModelRequest(
Long agentId, Long taskId,
String agentName,
String systemPrompt,
String userMessage,
String modelConfigJson
) {
}
public record AgentModelResponse(String text) {
}
业务方给的是"系统提示词 + 用户消息 + 模型配置",拿回的是"一段文本"。没有 messages 数组、没有工具协议、没有多模态结构——因为这些不是业务方该操心的事。业务方的世界观里只有"问一句话,答一段话",OpenAI 协议的细节被完全封在接入层之下。
这个契约窄得近乎吝啬,但它是换供应商时"业务零改动"的前提:如果契约里露出了某家供应商特有的字段(比如 reasoning_content),换供应商那天业务代码就要跟着改。接口越窄,实现越自由。
二、协议层:OpenAI 兼容是最大公约数
向下,客户端按 OpenAI 兼容协议实现(OpenAiCompatibleAgentModelClient)。这不是偏爱 OpenAI,而是行业现实:主流模型服务几乎都提供 OpenAI 兼容端点,它就是事实上的最大公约数。锁定这个协议,“换供应商"就从"重写客户端"降级为"改 baseUrl 和模型名”。
超时、最大 token、温度这些运行参数从配置注入,全部带默认值:
private Duration timeout = Duration.ofSeconds(30);
private Integer maxTokens = 1024;
private Double temperature = 0.2D;
温度默认 0.2 是个值得单独说的选择。企业场景的 AI 用途(预审、翻译、助手问答)要的是稳定和准确,不是创造性——温度调高换来的"灵动"在这个场景里是风险。把保守值设为默认,让需要创造力的场景显式地把它调高,错误方向才安全。
三、错误分型:失败不是一团黑
接入层最容易被省略、又最不能省的部分:把各种失败翻译成有限的语言。
private AgentModelException mapResponseException(RestClientResponseException e) {
if (/* 401/403 */) return new AgentModelException("AUTH_FAILED", "模型服务认证失败", e);
if (/* 429 */) return new AgentModelException("RATE_LIMITED", "模型服务限流", e);
...
}
// 连接层异常单独分型
catch (RestClientException e) {
if (/* 超时 */) throw new AgentModelException("TIMEOUT", "模型服务调用超时", e);
}
// 响应不合格式
throw new AgentModelException("INVALID_RESPONSE", "模型服务返回内容为空");
业务方拿到的失败不再是"抛了个异常",而是带语义的枚举:TIMEOUT(超时)、AUTH_FAILED(认证失败)、RATE_LIMITED(限流)、INVALID_RESPONSE(响应不合法)。
为什么要分型?因为不同类型的失败有不同的善后:超时可以重试、限流应该退避等待、认证失败重试无意义且要立刻告警、响应不合法可能是提示词问题。管理端的失败聚合页也按这个分型统计——"这周 AI 助手失败 40 次"没有行动价值,"40 次里 32 次 RATE_LIMITED 集中在下午三点"才有。
这是通用的一课:封装外部服务时,除了封装"成功路径",还要把对方的失败语言翻译成自己的失败语言。让上游穿透一个 HTTP 状态码,就等于让上游永远绑定在对方的错误语义上。
四、工厂:每个助手可以有不同配置
客户端之上有一层工厂(SpringAiOpenAiChatClientFactory),按"解析后的模型配置"创建对应的聊天客户端:
public OpenAiChatClient create(ResolvedAgentModelConfig config) {
RestClient.Builder restClientBuilder = RestClient.builder()...;
OpenAiApi openAiApi = new OpenAiApi(
config.baseUrl(),
config.apiKey(), ...);
OpenAiChatOptions options = OpenAiChatOptions.builder()...;
...
}
它服务的现实是:企业里不止一个模型。全局配置给个兜底(AgentModelProperties,含开关、baseUrl、默认模型),但每个 AI 助手可以配自己的模型——便宜的模型做消息翻译,能力强的模型做审批预审。配置以 JSON 存在每个助手的版本里,和系列之前说的"版本快照"呼应:任务执行时用哪个模型,锁定在创建时刻的配置上。
全局开关(enabled)的语义也要明确:关掉后所有 AI 功能返回"未启用"的业务错误,而不是让请求在超时边缘挣扎。模型服务的可用性是概率性的,业务侧要有一个确定性的闸门。
五、健康检查:把"服务活着"变成定时确认
供应商状态页没人看,用户反馈永远滞后。所以接入层配了一个定时健康检查(AiProviderHealthCheckJobService):按配置的间隔逐个探测已登记的模型端点,结果落库,管理端可以看到每个供应商的最近状态。
这套东西的运行逻辑和系列之前写的任务租约锁是同一套骨架:定时触发、抢锁防重、流水留痕。区别只在探测的内容——一次最小代价的模型调用(或健康端点),验证"端点通、密钥对、模型在"。
它买来的是故障的提前暴露:模型服务半夜挂了,管理员早上的巡检页上是一个红色的"探测失败",而不是一堆用户工单。
六、小结
三点体会:
- 接入层的价值 = 窄契约 + 翻译。 对上只暴露"问答"这一种形状,对下把协议细节和失败语言都翻译掉——业务代码于是可以假装"模型服务是个稳定的函数"。
- OpenAI 兼容协议是务实的选择。 它不是最好的协议,是最大的公约数;选它,供应商切换的成本接近于零。
- 错误分型决定运维能力。 没有分型的失败是黑盒,有分型的失败是仪表盘——在接入层多花的那五十行,省下的是无数次"AI 又失败了"的排查。
上周供应商发了次价格调整通知,顺手把默认模型换成了另一家的同档产品:改了两行配置,重启,完事。全项目五十多处 AI 调用点,没有一处需要动——这就是这层抽象全部的意义。
默认评论
Halo系统提供的评论