agentlog 的设计核心是一条 traceid 贯穿一次生成任务,一个 stage 记录一个阶段,JSON 存出入参,token 与耗时单独计量。表结构围绕可观测性建:traceid、agentname、stage、req/resp JSON、prompttokens、completiontokens、costms、status、errormsg、createtime。索引只建 traceid 与 create_time 两个核心,其他字段靠 JSON 检索或归档后分析,避免日志表变成索引重灾区。
一、日志表要回答的三个问题
多阶段生成每次任务要调用多次大模型,出问题时最常问三件事:
- 这篇文章生成到哪一步失败的,失败在哪个阶段?
- 那次调用的入参提示词是什么,模型返回了什么?
- 消耗了多少 token、耗时多久,成本花在哪里?
agentlog 就是为这三个问题建的。它与 article 表的分工:article 存”产出的结果”,agentlog 存”生成的过程”。
二、字段设计
| 字段 | 类型 | 说明 |
|---|---|---|
| id | BIGINT | 主键 |
| trace_id | VARCHAR(64) | 一次生成任务的全链路 ID |
| task_id | BIGINT | 关联生成任务表 |
| article_id | BIGINT | 关联文章 |
| agent_name | VARCHAR(64) | 阶段名:topic/outline/draft/polish |
| stage | TINYINT | 阶段序号,与 agent_name 对应 |
| attempt | TINYINT | 该阶段第几次尝试 |
| req_params | JSON | 入参:提示词、模型、温度等 |
| resp_result | JSON | 模型最终返回 |
| prompt_tokens | INT | 输入 token 数 |
| completion_tokens | INT | 输出 token 数 |
| total_tokens | INT | 合计 |
| cost_ms | BIGINT | 该次调用耗时毫秒 |
| status | TINYINT | 0 成功 1 失败 2 超时 |
| error_msg | VARCHAR(500) | 失败摘要 |
| error_stack | TEXT | 完整异常堆栈 |
| create_time | DATETIME | 入库时间 |
reqparams 和 respresult 用 MySQL JSON 类型而不是 TEXT。JSON 字段在 8.0 有原生校验和 jsonextract 能力,排查时按需提取,不用把整条日志拉出来解析。respresult 只存最后一次有效返回,完整的流式 chunk 序列不上库——那是几百条小片段,直接写日志文件或对象存储,数据库只留聚合结果。
三、trace_id 怎么贯穿链路
traceid 在创建生成任务时生成,流式回调的每个阶段都带上它。前端展示进度时按 traceid 查各阶段日志,后端定位问题时一条 SQL 拉出全链路:
SELECT stage, agent_name, status, cost_ms, total_tokens, error_msg
FROM agent_log WHERE trace_id = 'xxx' ORDER BY id;
同一任务重试会产生多条同 stage 日志,用 attempt 字段区分第几次尝试,保留每次的入参出参,方便对比失败与成功路径的差异。
四、索引与存储策略
日志表的查询模式很固定:按 traceid 拉链路、按时间段分析、按 agentname 统计成功率。索引不需要多:
idx_trace_id(trace_id):全链路查询的核心入口;idx_create_time(create_time):时间范围分析;idx_agent_create(agentname, createtime):单阶段成功率、耗时统计。
不要给 status、errormsg 这类低区分度字段单独建索引。日志量大以后按月分区,agentlog 保留最近三个月热数据,更早的归档到独立库,分区裁剪让时间范围查询只扫需要的分区。
五、异步落库,不拖慢流式主链路
日志写在异步线程里。生成任务本身要等大模型返回几十秒,同步落库会把等待时间再拉长。实现上用一个独立线程池,方法内只组装 AgentLog 对象,插入动作丢给线程池:
@Component
public class AgentLogRecorder {
private final AgentLogMapper logMapper;
private final ExecutorService executor = Executors.newFixedThreadPool(2);
public void record(AgentLog log) {
log.setCreateTime(LocalDateTime.now());
executor.execute(() -> logMapper.insert(log));
}
}
线程池容量要控制好,日志写入失败只记 error 日志,绝不能影响生成主流程。批量写入用 saveBatch 合并,避免每个阶段一次单条 insert。
六、与 AOP 配合,日志代码不进业务
记录动作不散落在各阶段代码里。在调用 AI 的服务方法上打自定义注解,AOP 切面统一采集入参、执行耗时、异常堆栈,组装成 AgentLog 交给 recorder。业务代码里只有一行注解,日志逻辑集中在切面维护,阶段代码只管生成,不关心日志怎么落。
常见问题(FAQ)
Q1:agent_log 直接存完整流式响应吗?
不存。只存最后一次有效返回,流式 chunk 序列写日志文件。
Q2:日志量增长很快怎么处理?
按 create_time 分区或按月归档,热库只保留最近三个月。
Q3:token 统计在哪一步拿到的?
流式结束时从 ChatResponse 元数据聚合,按阶段累加入库。