Skip to content

请求记录 ​

在多平台、多模型的生产系统里,稳定性问题往往不是“模型能力不行”,而是缺少一套可追溯、可统计、可告警的调用记录体系。java-openai 通过统一的 UniChatRequest / UniChatResponse 抽象,把模型协议差异隔离在适配层;而在业务层,建议再配套一层“请求记录”能力,把每一次调用的关键要素完整落库,形成可观测性闭环。

本节给出一套可直接落地的方案:

  • 成功请求记录表:tio_llm_usage
  • 失败请求记录表:tio_llm_generate_failed
  • 统一调用入口:UniPredictService
  • 成功落库:TioLlmUsageDao.saveUsage
  • 失败落库:TioLlmGenerateFailedDao.save
  • 同时集成告警(示例为 LarkBot),并保留原始 request/response 便于排障

重点在于:业务侧并不需要关心平台协议,落库的字段来自统一抽象与异常对象中携带的“真实请求”。


1. 表结构设计 ​

1.1 成功调用表 tio_llm_usage ​

该表用于记录每一次成功生成的调用信息,用于统计、审计与成本分析。

sql
drop table if exists tio_llm_usage;
CREATE TABLE tio_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 失败调用表 tio_llm_generate_failed ​

该表用于保存每一次失败请求的完整上下文,核心价值是:保留“真实请求 JSON、响应 JSON 与堆栈”,让排障不依赖复现。

sql
drop table if exists tio_llm_generate_failed;
CREATE TABLE tio_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 ​

建议所有业务调用都通过统一入口进行,这样才能把以下逻辑集中治理:

  • 统一代理与网络策略(例如境内走代理)
  • 统一重试
  • 成功落库
  • 失败落库
  • 统一告警

示例实现如下:

java
package com.vt.mc.qa.proxy;

import java.io.PrintWriter;
import java.io.StringWriter;

import nexus.io.chat.UniChatClient;
import nexus.io.chat.UniChatRequest;
import nexus.io.chat.UniChatResponse;
import nexus.io.consts.ModelPlatformName;
import nexus.io.exception.GenerateException;
import nexus.io.jfinal.aop.Aop;
import nexus.io.manim.dao.TioLlmGenerateFailedDao;
import nexus.io.manim.dao.TioLlmUsageDao;
import nexus.io.tio.utils.SystemTimer;
import nexus.io.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(TioLlmUsageDao.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(TioLlmGenerateFailedDao.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;
  }
}

关键点拆解:

  1. 代理与路由策略前置 根据 isChina() 与平台判断,动态改写 apiPrefixUrl,让业务调用不需要写任何网络环境判断逻辑。

  2. 成功落库:记录 usage 与耗时 saveUsage(uniChatRequest, uniChatResponse, elapsed) 在成功返回前执行,保证每次成功调用都有完整记录。

  3. 失败落库:记录真实请求与响应 捕获 GenerateException 后,直接从异常对象拿到 request/response,这一点对排障极其重要,因为它记录的是“最终发出的协议请求”。

  4. 统一告警 将 url、requestJson、statusCode、responseBody、stackTrace 发送到告警系统,做到“失败即通知,通知带上下文”。


3. 成功落库:TioLlmUsageDao.saveUsage ​

java
package nexus.io.tio.boot.admin.dao;

import java.util.List;

import nexus.io.chat.UniChatMessage;
import nexus.io.chat.UniChatRequest;
import nexus.io.chat.UniChatResponse;
import nexus.io.db.activerecord.Row;
import nexus.io.db.base.DbBase;
import nexus.io.openai.chat.ChatResponseMessage;
import nexus.io.tio.boot.admin.dto.TioLlmUsage;
import nexus.io.tio.utils.json.JsonUtils;
import nexus.io.tio.utils.snowflake.SnowflakeIdUtils;

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class TioLlmUsageDao extends DbBase {

  @Override
  public String getTableName() {
    return TioLlmUsage.tableName;
  }

  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. 失败落库:TioLlmGenerateFailedDao.save ​

java
package nexus.io.tio.boot.admin.dao;

import nexus.io.chat.UniChatRequest;
import nexus.io.db.DbJsonObject;
import nexus.io.db.activerecord.Row;
import nexus.io.db.base.DbBase;
import nexus.io.exception.GenerateException;
import nexus.io.tio.boot.admin.dto.TioLlmGenerateFailed;
import nexus.io.tio.utils.snowflake.SnowflakeIdUtils;

public class TioLlmGenerateFailedDao extends DbBase {

  @Override
  public String getTableName() {
    return TioLlmGenerateFailed.tableName;
  }

  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. 与协议自动转换的结合价值 ​

把“请求记录”与“协议自动转换”放在一起,才能形成完整闭环:

  • 业务侧只写 UniChatRequest

  • java-openai 自动转换为平台原生协议

  • 一旦失败,异常里携带的 request_body 就是转换后的最终请求

  • 将其写入 tio_llm_generate_failed,即可做到:

    • 可追溯
    • 可审计
    • 可复盘
    • 可快速定位适配层是否存在字段映射问题

换句话说:

  • 自动转换负责屏蔽差异
  • 请求记录负责让差异可见、可验证、可治理

这两者组合在一起,才是生产系统可长期维护的关键。


6. 生产建议与风险控制 ​

  1. 数据合规与脱敏 system_prompt、messages、content 可能包含敏感数据,应按业务要求脱敏、加密或做抽样保存。

  2. 限制失败表的增长 失败请求可能在故障期爆炸式增长,建议:

    • 按天分区或设置 TTL
    • 对相同错误做聚合去重
    • 或将大字段(request_body/response_body)迁移到对象存储
  3. 重试策略需要区分错误类型 建议在 GenerateException 中根据 statusCode 做策略:

    • 401/403 不要重试
    • 429/5xx 可退避重试
    • 网络超时可有限重试并记录超时分类
  4. 记录 traceId 若系统有链路追踪,建议在表中增加 traceId,方便将一次模型调用与上游请求关联起来。


7. 小结 ​

本节给出了一套围绕 java-openai 的统一抽象实现的请求记录体系:

  • 成功表记录 usage、耗时、输出与上下文
  • 失败表记录真实请求、响应与堆栈
  • 统一入口集中代理策略、重试、落库与告警
  • 与协议自动转换结合后,能快速验证转换结果并高效排障

MIT Licensed