请求记录
在多平台、多模型的生产系统里,稳定性问题往往不是“模型能力不行”,而是缺少一套可追溯、可统计、可告警的调用记录体系。java-openai 通过统一的 UniChatRequest / UniChatResponse 抽象,把模型协议差异隔离在适配层;而在业务层,建议再配套一层“请求记录”能力,把每一次调用的关键要素完整落库,形成可观测性闭环。
本节给出一套可直接落地的方案:
- 成功请求记录表:
mv_llm_usage - 失败请求记录表:
mv_llm_generate_failed - 统一调用入口:
UniPredictService - 成功落库:
MvLlmUsageDao.saveUsage - 失败落库:
MvLlmGenerateFailedDao.save - 同时集成告警(示例为 LarkBot),并保留原始 request/response 便于排障
重点在于:业务侧并不需要关心平台协议,落库的字段来自统一抽象与异常对象中携带的“真实请求”。
1. 表结构设计
1.1 成功调用表 mv_llm_usage
该表用于记录每一次成功生成的调用信息,用于统计、审计与成本分析。
drop table if exists mv_llm_usage;
CREATE TABLE mv_llm_usage (
id BIGINT PRIMARY KEY,
group_id bigint,
group_name varchar,
task_id bigint,
task_name varchar,
model varchar,
system_prompt varchar,
messages varchar,
content varchar,
usage varchar,
provider varchar,
api_key varchar,
elapsed bigint,
status int,
"creator" VARCHAR ( 64 ) DEFAULT '',
"create_time" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updater" VARCHAR ( 64 ) DEFAULT '',
"update_time" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted" SMALLINT DEFAULT 0,
"tenant_id" BIGINT NOT NULL DEFAULT 0
);
字段说明与建议:
- group_id / group_name / task_id / task_name 用于业务分组与任务维度统计。它们来自
UniChatRequest,不参与模型推理,只用于业务治理与可观测性。 - provider / model / api_key 用于统计不同平台与模型的消耗、成功率与延迟。生产环境建议对 api_key 做脱敏或只存 key 的标识。
- system_prompt / messages 用于复盘与定位问题。若涉及敏感数据,可做脱敏、摘要化或只存 hash。
- content 记录最终输出,便于验收与抽检。同样建议根据合规要求做脱敏或抽样保存。
- usage 记录 token 消耗等信息,支持成本核算与限额控制。
- elapsed 端到端耗时,建议统一以毫秒保存。
- status / deleted / tenant_id 适配多租户与软删除体系,便于运营与归档。
1.2 失败调用表 mv_llm_generate_failed
该表用于保存每一次失败请求的完整上下文,核心价值是:保留“真实请求 JSON、响应 JSON 与堆栈”,让排障不依赖复现。
drop table if exists mv_llm_generate_failed;
CREATE TABLE mv_llm_generate_failed (
id BIGINT PRIMARY KEY,
group_id bigint,
group_name varchar,
task_id bigint,
task_name varchar,
provider varchar,
request_url varchar,
request_body jsonb,
response_code int,
response_body jsonb,
exception varchar,
api_key varchar,
elapsed bigint,
status int,
"creator" VARCHAR ( 64 ) DEFAULT '',
"create_time" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updater" VARCHAR ( 64 ) DEFAULT '',
"update_time" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted" SMALLINT DEFAULT 0,
"tenant_id" BIGINT NOT NULL DEFAULT 0
);
字段说明与建议:
- request_url / request_body 记录最终请求的目标地址与请求体。这里的 request_body 是平台真实收到的协议 JSON,来源于
GenerateException,非常适合用来验证协议自动转换是否正确。 - response_code / response_body 记录平台返回的状态码与响应体,用于区分鉴权失败、限流、参数错误、网关错误等。
- exception 保存完整堆栈,配合 request/response 可以快速定位是业务侧参数问题、适配层问题还是平台不稳定。
- elapsed 记录失败请求耗时,便于识别超时、网络抖动或重试风暴。
2. 统一调用入口:UniPredictService
建议所有业务调用都通过统一入口进行,这样才能把以下逻辑集中治理:
- 统一代理与网络策略(例如境内走代理)
- 统一重试
- 成功落库
- 失败落库
- 统一告警
示例实现如下:
package com.vt.mc.qa.proxy;
import java.io.PrintWriter;
import java.io.StringWriter;
import com.litongjava.chat.UniChatClient;
import com.litongjava.chat.UniChatRequest;
import com.litongjava.chat.UniChatResponse;
import com.litongjava.consts.ModelPlatformName;
import com.litongjava.exception.GenerateException;
import com.litongjava.jfinal.aop.Aop;
import com.litongjava.manim.dao.MvLlmGenerateFailedDao;
import com.litongjava.manim.dao.MvLlmUsageDao;
import com.litongjava.tio.utils.SystemTimer;
import com.litongjava.tio.utils.environment.EnvUtils;
import com.vt.mc.qa.services.AppConfigUtils;
import com.vt.mc.qa.utils.LarkBotUtils;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class UniPredictService {
public UniChatResponse generate(UniChatRequest uniChatRequest) {
String platform = uniChatRequest.getPlatform();
boolean china = AppConfigUtils.isChina();
if (china) {
if (ModelPlatformName.OPENROUTER.equals(platform)) {
String basePrefixUrl = EnvUtils.getStr("OPENROUTER_PROXY_BASE_URL");
uniChatRequest.setApiPrefixUrl(basePrefixUrl);
}
}
for (int i = 0; i < 10; i++) {
try {
long currentTime = SystemTimer.currTime;
UniChatResponse uniChatResponse = UniChatClient.generate(uniChatRequest);
long endTime = SystemTimer.currTime;
Aop.get(MvLlmUsageDao.class).saveUsage(uniChatRequest, uniChatResponse, (endTime - currentTime));
return uniChatResponse;
} catch (GenerateException e) {
log.error(e.getMessage(), e);
String urlPerfix = e.getUrlPerfix();
String requestJson = e.getRequestBody();
Integer statusCode = e.getStatusCode();
String responseBody = e.getResponseBody();
StringWriter sw = new StringWriter();
PrintWriter pw = new PrintWriter(sw);
e.printStackTrace(pw);
String stackTrace = sw.toString();
try {
Aop.get(MvLlmGenerateFailedDao.class).save(uniChatRequest, e, stackTrace);
} catch (Exception e1) {
log.error(e.getMessage(), e1);
}
String name = "tio-boot";
String warningName = "LlmOcrService Failed to request ExchangeToken:" + uniChatRequest.getTaskName();
LarkBotUtils.sendException(name, warningName, urlPerfix, requestJson, statusCode, responseBody, stackTrace);
}
}
return null;
}
}
关键点拆解:
代理与路由策略前置 根据
isChina()与平台判断,动态改写apiPrefixUrl,让业务调用不需要写任何网络环境判断逻辑。成功落库:记录 usage 与耗时
saveUsage(uniChatRequest, uniChatResponse, elapsed)在成功返回前执行,保证每次成功调用都有完整记录。失败落库:记录真实请求与响应 捕获
GenerateException后,直接从异常对象拿到 request/response,这一点对排障极其重要,因为它记录的是“最终发出的协议请求”。统一告警 将 url、requestJson、statusCode、responseBody、stackTrace 发送到告警系统,做到“失败即通知,通知带上下文”。
3. 成功落库:MvLlmUsageDao.saveUsage
package com.litongjava.manim.dao;
import java.util.List;
import com.litongjava.chat.UniChatMessage;
import com.litongjava.chat.UniChatRequest;
import com.litongjava.chat.UniChatResponse;
import com.litongjava.db.activerecord.Db;
import com.litongjava.db.activerecord.Row;
import com.litongjava.db.base.DbBase;
import com.litongjava.gemini.GeminiChatRequest;
import com.litongjava.gemini.GeminiChatResponse;
import com.litongjava.manim.consts.MvTableName;
import com.litongjava.openai.chat.ChatResponseMessage;
import com.litongjava.tio.utils.json.JsonUtils;
import com.litongjava.tio.utils.snowflake.SnowflakeIdUtils;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class MvLlmUsageDao extends DbBase {
@Override
public String getTableName() {
return MvTableName.mv_llm_usage;
}
public void saveUsage(UniChatRequest uniChatRequest, UniChatResponse uniChatResponse, long elapsed) {
if (uniChatResponse == null) {
log.warn("Skipping saveUsage for task {} as generated response is null", uniChatRequest.getTaskId());
return;
}
String systemPrompt = uniChatRequest.getSystemPrompt();
List<UniChatMessage> messages = uniChatRequest.getMessages();
String skipNullJson = JsonUtils.toSkipNullJson(messages);
Row row = Row.by("id", SnowflakeIdUtils.id()).set("group_id", uniChatRequest.getGroupId())
.set("group_name", uniChatRequest.getGroupName())
//
.set("task_id", uniChatRequest.getTaskId()).set("task_name", uniChatRequest.getTaskName())
//
.set("provider", uniChatRequest.getPlatform()).set("api_key", uniChatRequest.getApiKey())
.set("model", uniChatRequest.getModel())
//
.set("usage", uniChatResponse.getUsage() != null ? JsonUtils.toSkipNullJson(uniChatResponse.getUsage()) : null)
//
.set("system_prompt", systemPrompt).set("messages", skipNullJson)
//
.set("elapsed", elapsed);
try {
ChatResponseMessage message = uniChatResponse.getMessage();
if (message != null) {
String content = message.getContent();
row.set("content", content);
}
} catch (Exception e) {
log.error(e.getMessage(), e);
}
try {
super.save(row);
log.debug("Saved LLM usage for task: {}", uniChatRequest.getTaskId());
} catch (Exception e) {
log.error("Failed to save LLM usage for task: {}", uniChatRequest.getTaskId(), e);
}
}
}
设计要点:
- messages 保存为 JSON 字符串(跳过 null 字段) 既能复盘,又能减少冗余数据。
- usage 单独保存 便于后续做成本统计与配额控制。
- content 保存最终输出 可做抽检、回归测试与质量评估。
- elapsed 与 task 维度绑定 可做性能分析,定位慢请求来源。
建议增强点(可选):
- api_key 建议脱敏或只保留 keyId
- content、messages、system_prompt 可能包含敏感信息时要做脱敏或加密
- 可增加 responseId、traceId、cachedId 等字段用于链路追踪与缓存命中分析
4. 失败落库:MvLlmGenerateFailedDao.save
package com.litongjava.manim.dao;
import com.litongjava.chat.UniChatRequest;
import com.litongjava.db.DbJsonObject;
import com.litongjava.db.activerecord.Row;
import com.litongjava.db.base.DbBase;
import com.litongjava.exception.GenerateException;
import com.litongjava.manim.consts.MvTableName;
import com.litongjava.tio.utils.snowflake.SnowflakeIdUtils;
public class MvLlmGenerateFailedDao extends DbBase {
@Override
public String getTableName() {
return MvTableName.mv_llm_generate_failed;
}
public boolean save(UniChatRequest uniChatRequest, GenerateException e, String stackTrace) {
Long groupId = uniChatRequest.getGroupId();
String groupName = uniChatRequest.getGroupName();
Long taskId = uniChatRequest.getTaskId();
String taskName = uniChatRequest.getTaskName();
String apiKey = uniChatRequest.getApiKey();
String provider = uniChatRequest.getPlatform();
String urlPerfix = e.getUrlPerfix();
String requestJson = e.getRequestBody();
Integer statusCode = e.getStatusCode();
String responseBody = e.getResponseBody();
return this.save(groupId, groupName, taskId, taskName, apiKey, provider, urlPerfix, requestJson, statusCode,
responseBody, stackTrace, e.getElapsed());
}
public boolean save(Long groupId, String groupName, Long taskId, String taskName, String apiKey, String provider,
String urlPerfix, String requestJson, Integer statusCode, String responseBody, String stackTrace,Long elapsed) {
Row row = Row.by("id", SnowflakeIdUtils.id()).set("group_id", groupId).set("group_name", groupName)
.set("task_id", taskId).set("task_name", taskName)
//
.set("api_key", apiKey).set("provider", provider)
//
.set("request_url", urlPerfix).set("request_body", new DbJsonObject(requestJson))
//
.set("response_code", statusCode).set("response_body", new DbJsonObject(responseBody))
//
.set("exception", stackTrace).set("elapsed", elapsed);
return super.save(row);
}
}
为什么失败落库要存 request_body 与 response_body:
- 绝大多数模型调用失败并不需要复现,只需要看请求与响应就能定位 例如鉴权错误、限流、参数不合法、模型不可用、网关错误等。
- request_body 是最终的“平台协议 JSON” 这对验证 java-openai 的协议自动转换是否正确非常关键。
- response_body 保存平台原始错误信息 便于快速区分问题责任归属:业务参数、适配层、平台稳定性、网络环境。
5. 与协议自动转换的结合价值
把“请求记录”与“协议自动转换”放在一起,才能形成完整闭环:
业务侧只写
UniChatRequestjava-openai 自动转换为平台原生协议
一旦失败,异常里携带的 request_body 就是转换后的最终请求
将其写入
mv_llm_generate_failed,即可做到:- 可追溯
- 可审计
- 可复盘
- 可快速定位适配层是否存在字段映射问题
换句话说:
- 自动转换负责屏蔽差异
- 请求记录负责让差异可见、可验证、可治理
这两者组合在一起,才是生产系统可长期维护的关键。
6. 生产建议与风险控制
数据合规与脱敏 system_prompt、messages、content 可能包含敏感数据,应按业务要求脱敏、加密或做抽样保存。
限制失败表的增长 失败请求可能在故障期爆炸式增长,建议:
- 按天分区或设置 TTL
- 对相同错误做聚合去重
- 或将大字段(request_body/response_body)迁移到对象存储
重试策略需要区分错误类型 建议在
GenerateException中根据 statusCode 做策略:- 401/403 不要重试
- 429/5xx 可退避重试
- 网络超时可有限重试并记录超时分类
记录 traceId 若系统有链路追踪,建议在表中增加 traceId,方便将一次模型调用与上游请求关联起来。
7. 小结
本节给出了一套围绕 java-openai 的统一抽象实现的请求记录体系:
- 成功表记录 usage、耗时、输出与上下文
- 失败表记录真实请求、响应与堆栈
- 统一入口集中代理策略、重试、落库与告警
- 与协议自动转换结合后,能快速验证转换结果并高效排障
