上周五下午,我把上一个律所项目的日志目录整个拉下来翻了一遍。
不是出了故障。是合同里质保期 90 天,快到期了,我想在到期之前确认一件事:如果客户现在打来电话说上次问的那个问题答案不对,我能不能在半小时内给出原因,而不是回一句「我看一下」。
翻完,心里有底了。但这个底气不是模型给的。模型这三个月一行没改。给我底气的是交付时多配的三样东西:日志分目录、发布单元、基线版本。
这篇文章讲的就是这三样,以及它们各自挡住的是什么。
一、质保期会坏的东西,基本不在模型里
企业级 Agent 交付出去之后,出问题的位置跟大多数人想的不一样。
我们那个律所项目,一个问题进来是这样走的:意图识别、RAG 检索、Skill 生成、客户打标、业务落库、Trace 审计。六段。模型只在 Skill 生成那一段承担对外调用,外加检索前后的两处重排。
也就是说,模型在整条链路里占的位置不到一半。剩下那些位置上的东西,每天都在变:
- 知识库在补新文书,切片跟着变
- 提示词按客户反馈微调,一周动两三次
- 依赖库在升,Go 和 Python 两边都升过
- 数据量在涨,第一周一万条,第三个月十几万条
模型没动,周围全动了。质保期里真正会出事的,就是这四样。我把它们归成三处:日志留存、版本回滚、数据口径。
举个例子。这个领域里最典型的一类问题,是当事人手里只有一堆聊天记录,想知道能不能起诉、胜算多大。这类问题走完六段链路要调三次模型。三个月后再问同样的问题,模型还是那个模型,但知识库里多了几百份文书,切片策略也调过一次,答案就变了。客户不会记得中间发生过什么,他只记得上次不是这么说的。
很多团队觉得有测试就够了。这个项目我们留了 665 条自动化测试,Go 334 条、Python 320 条、Playwright 11 条。听着挺多。但它们跑在固定的知识库快照上,快照一换,绿灯代表的意义就变了。测试挡不住环境漂移。
二、第一处:日志留存,最容易在第三个月出事
上线第一周什么都留。请求、响应、检索回来的片段、工具调用参数、耗时,全写。
第三个月磁盘告警。有人写了个清理脚本,按 7 天轮转。脚本里少了一层目录判断,把整个日志目录都扫了。
结果不是磁盘干净了,是审计日志没了。客户追三个月前的一个结论,你调不出当时检索到哪几段,只能说大概是旧数据的问题。这句话在验收会上站不住。
我的做法很土,但有效:审计日志和调试日志物理上放两个目录,清理逻辑只认调试目录那一个路径。
type LogPolicy struct {
AuditDir string
DebugDir string
DebugKeepDay int
}
func CheckLogRetention(p LogPolicy) (string, error) {
entries, err := os.ReadDir(p.AuditDir)
if err != nil {
return "", fmt.Errorf("审计日志目录读不到: %w", err)
}
// 只数文件,不数子目录:按天分目录时,空目录不能算有留存。
files := 0
for _, e := range entries {
if !e.IsDir() {
files++
}
}
if files == 0 {
return "", errors.New("审计目录为空,出问题无法追溯")
}
cutoff := time.Now().AddDate(0, 0, -p.DebugKeepDay)
de, err := os.ReadDir(p.DebugDir)
if err != nil {
return "", fmt.Errorf("调试日志目录读不到: %w", err)
}
removed := 0
for _, e := range de {
if e.IsDir() {
continue
}
info, err := e.Info()
if err != nil || info.ModTime().After(cutoff) {
continue
}
if err := os.Remove(filepath.Join(p.DebugDir, e.Name())); err != nil {
return "", err
}
removed++
}
left, _ := os.ReadDir(p.DebugDir)
return fmt.Sprintf("审计留存 %d 个文件 / 调试清理 %d 个、剩余 %d 个", files, removed, len(left)), nil
}
要点有三个。
审计目录里一个文件都没有就直接报错,不等出事再发现。统计时只数文件不数子目录,因为按天分目录的场景下,一堆空目录会让检查假通过。
删文件的那行永远拼在 p.DebugDir 下面。审计目录在整个函数里只出现在 os.ReadDir 的参数里,没有任何一次写操作。人紧张的时候会手滑,路径不会。
三、第二处:版本回滚,很多人只回了一半
这是质保期里最贵的一处。
出问题时大家的第一反应是「回上一版」。回上一版什么?代码回到上一个 commit。然后呢。
提示词还是新的。知识库是上周重建过的。索引版本没动。于是代码是旧的、提示词是新的、知识库是更新的,这个组合从来没一起测过。老问题没解决,还添了个新问题。
正确做法是把它们绑成一个发布单元,四项一起走:代码 commit、提示词版本、知识库快照、索引版本。回滚就四项一起回,缺一项拒绝执行。
type ReleaseUnit struct {
CodeCommit string `json:"code_commit"`
PromptVer string `json:"prompt_ver"`
KBSnapshot string `json:"kb_snapshot"`
IndexVer string `json:"index_ver"`
}
func CheckRollbackUnit(now, target ReleaseUnit) ([]string, error) {
var missing []string
if target.CodeCommit == "" {
missing = append(missing, "code_commit")
}
if target.PromptVer == "" {
missing = append(missing, "prompt_ver")
}
if target.KBSnapshot == "" {
missing = append(missing, "kb_snapshot")
}
if target.IndexVer == "" {
missing = append(missing, "index_ver")
}
if len(missing) > 0 {
return nil, fmt.Errorf("目标版本缺 %v,拒绝回滚", missing)
}
var steps []string
if now.CodeCommit != target.CodeCommit {
steps = append(steps, "code "+now.CodeCommit+" -> "+target.CodeCommit)
}
if now.PromptVer != target.PromptVer {
steps = append(steps, "prompt "+now.PromptVer+" -> "+target.PromptVer)
}
if now.KBSnapshot != target.KBSnapshot {
steps = append(steps, "kb "+now.KBSnapshot+" -> "+target.KBSnapshot)
}
if now.IndexVer != target.IndexVer {
steps = append(steps, "index "+now.IndexVer+" -> "+target.IndexVer)
}
return steps, nil
}
这段代码的价值在「拒绝」两个字。人紧张的时候会想先回滚代码试试,程序不让。缺两项就报「拒绝回滚」,逼你把四项找齐。
四、第三处:数据口径变了,基线没跟着变
第三处最隐蔽。
验收的时候我们测过一轮,那个命中率写进了验收单。
三个月里客户陆续补了一批文书,切片策略跟着调了一次。命中率还是按原样报,但那是旧切片下的数字。口径已经变了,数字没跟着变。客户平时不会发现,直到某天他拿一条明明该命中的问题来问,你回一句我们的命中率是验收时那个数,场面就很难看。
处理办法是把基线绑死在知识库快照上。快照摘要变了,旧基线直接判作废,必须重测。
type Baseline struct {
Version string `json:"version"`
KBDigest string `json:"kb_digest"`
SampleNum int `json:"sample_num"`
HitRate float64 `json:"hit_rate"`
}
func CheckBaseline(curDigest string, b Baseline) error {
if curDigest != b.KBDigest {
return fmt.Errorf("基线 %s 绑定快照 %s,当前快照 %s,口径已变,基线作废", b.Version, b.KBDigest, curDigest)
}
return nil
}
判断只有三行,但它是质保期里唯一会主动告诉你「你现在报的数字已经不能用了」的地方。测试不会说,监控不会说,只有这个会说。
五、一张可保存的质保期检查表
| 检查项 | 在哪查 | 不通过怎么办 |
|---|---|---|
| 审计目录有文件留存 | 审计目录文件数(不数子目录) | 立刻停掉所有清理任务 |
| 清理脚本只覆盖调试目录 | 清理脚本里的路径拼接 | 改成只拼调试目录 |
| 三个月前的记录能读回 | 按 trace_id 抽样 | 补归档,补完再验收 |
| 审计写入没被采样策略吃掉 | 采样配置 | 审计链路设为不采样 |
| 发布单元四项齐全 | unit.json | 拒绝回滚,先找齐四项 |
| 回滚脚本能一次回四项 | 演练环境 | 补全回滚脚本再上线 |
| 线上实际版本与记录一致 | unit.json 对比线上 | 先对齐再回滚 |
| 知识库快照摘要已记录 | 快照元数据 | 补记并冻结当前快照 |
| 基线与快照摘要一致 | 基线文件 | 作废旧基线,重测 |
| 依赖版本已锁定 | go.mod / requirements.txt | 补锁后再发版 |
| 兜底行为仍生效 | 构造一次超时请求 | 修兜底分支 |
| 人工介入阈值没失效 | 阈值配置 | 重新定阈值并通知业务方 |
这张表不用背,直接贴在项目的 README 里,每周过一遍。过一遍大概十分钟。
六、把三处接起来跑一次
三个函数写完之后,我在每个项目的 main 里都接成同一段。下面是调用部分(节选):auditDir 和 debugDir 是前面的临时目录,status() 是一个把 error 转成 PASS 或 FAIL 字符串的小函数,都不影响这段的逻辑。
now := ReleaseUnit{"a1b2c3d", "prompt-v7", "kb-20260901", "idx-3"}
good := ReleaseUnit{"9f8e7d6", "prompt-v5", "kb-20260715", "idx-2"}
r1, err := CheckLogRetention(LogPolicy{AuditDir: auditDir, DebugDir: debugDir, DebugKeepDay: 7})
fmt.Println("1) 日志留存:", status(err), r1)
steps, err := CheckRollbackUnit(now, good)
fmt.Println("2) 版本回滚:", status(err), steps)
bad := ReleaseUnit{CodeCommit: "9f8e7d6", PromptVer: "prompt-v5"}
_, err = CheckRollbackUnit(now, bad)
fmt.Println(" 只回滚代码和提示词:", status(err))
b := Baseline{Version: "base-003", KBDigest: "kb-20260715", SampleNum: 200, HitRate: 0.86}
err = CheckBaseline("kb-20261012", b)
fmt.Println("3) 口径基线:", status(err))
out, _ := json.Marshal(good)
fmt.Println(" 目标发布单元:", string(out))
实跑输出:
1) 日志留存: PASS 审计留存 3 个文件 / 调试清理 1 个、剩余 1 个
2) 版本回滚: PASS [code a1b2c3d -> 9f8e7d6 prompt prompt-v7 -> prompt-v5 kb kb-20260901 -> kb-20260715 index idx-3 -> idx-2]
只回滚代码和提示词: FAIL 原因:目标版本缺 [kb_snapshot index_ver],拒绝回滚
3) 口径基线: FAIL 原因:基线 base-003 绑定快照 kb-20260715,当前快照 kb-20261012,口径已变,基线作废
目标发布单元: {"code_commit":"9f8e7d6","prompt_ver":"prompt-v5","kb_snapshot":"kb-20260715","index_ver":"idx-2"}
第 2 步那行 PASS 是四项齐全的正常回滚,紧跟着那行 FAIL 是只有代码和提示词的残缺回滚,被挡住了。第 3 步 FAIL 是快照摘要变了,旧基线作废,这两处都是这台自检程序主动告诉我该去处理的。
真实环境里我每周跑一次,跑完把这段输出贴进项目周报。业务方看到的是结论,我看到的是这三处有没有松动。
七、这三处跟面试的关系
面试官问「你们线上出问题怎么查」,大多数人答的是「看日志」。这三个字在面试官那里等于没答。
把这三处讲出来,答案是另一回事:审计日志和调试日志物理隔离,审计目录只被读、不被写;发布单元四项绑定,缺项拒绝回滚;基线绑在知识库快照摘要上,摘要变了自动作废。
这三句话没有一句涉及模型。
这也是我一直想跟做转型的同学说清楚的那件事:手里只有一个能聊天的 Demo,和交付过一个企业级项目,差的不是模型能力。Demo 能跑通六段链路,企业级项目要回答的是三个月后它还能不能说得清。
你们的质保期里,第三个月最容易松的是哪一处?评论区说说,我挑典型的单独写一篇。