质保期第 90 天我翻了一遍日志目录:交付后真正会坏的 3 处

上周五下午,我把上一个律所项目的日志目录整个拉下来翻了一遍。

不是出了故障。是合同里质保期 90 天,快到期了,我想在到期之前确认一件事:如果客户现在打来电话说上次问的那个问题答案不对,我能不能在半小时内给出原因,而不是回一句「我看一下」。

翻完,心里有底了。但这个底气不是模型给的。模型这三个月一行没改。给我底气的是交付时多配的三样东西:日志分目录、发布单元、基线版本。

这篇文章讲的就是这三样,以及它们各自挡住的是什么。

一、质保期会坏的东西,基本不在模型里

企业级 Agent 交付出去之后,出问题的位置跟大多数人想的不一样。

我们那个律所项目,一个问题进来是这样走的:意图识别、RAG 检索、Skill 生成、客户打标、业务落库、Trace 审计。六段。模型只在 Skill 生成那一段承担对外调用,外加检索前后的两处重排。

也就是说,模型在整条链路里占的位置不到一半。剩下那些位置上的东西,每天都在变:

  • 知识库在补新文书,切片跟着变
  • 提示词按客户反馈微调,一周动两三次
  • 依赖库在升,Go 和 Python 两边都升过
  • 数据量在涨,第一周一万条,第三个月十几万条

模型没动,周围全动了。质保期里真正会出事的,就是这四样。我把它们归成三处:日志留存、版本回滚、数据口径。

举个例子。这个领域里最典型的一类问题,是当事人手里只有一堆聊天记录,想知道能不能起诉、胜算多大。这类问题走完六段链路要调三次模型。三个月后再问同样的问题,模型还是那个模型,但知识库里多了几百份文书,切片策略也调过一次,答案就变了。客户不会记得中间发生过什么,他只记得上次不是这么说的。

很多团队觉得有测试就够了。这个项目我们留了 665 条自动化测试,Go 334 条、Python 320 条、Playwright 11 条。听着挺多。但它们跑在固定的知识库快照上,快照一换,绿灯代表的意义就变了。测试挡不住环境漂移。

二、第一处:日志留存,最容易在第三个月出事

上线第一周什么都留。请求、响应、检索回来的片段、工具调用参数、耗时,全写。

第三个月磁盘告警。有人写了个清理脚本,按 7 天轮转。脚本里少了一层目录判断,把整个日志目录都扫了。

结果不是磁盘干净了,是审计日志没了。客户追三个月前的一个结论,你调不出当时检索到哪几段,只能说大概是旧数据的问题。这句话在验收会上站不住。

我的做法很土,但有效:审计日志和调试日志物理上放两个目录,清理逻辑只认调试目录那一个路径。

go 复制代码
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、提示词版本、知识库快照、索引版本。回滚就四项一起回,缺一项拒绝执行。

go 复制代码
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
}

这段代码的价值在「拒绝」两个字。人紧张的时候会想先回滚代码试试,程序不让。缺两项就报「拒绝回滚」,逼你把四项找齐。

四、第三处:数据口径变了,基线没跟着变

第三处最隐蔽。

验收的时候我们测过一轮,那个命中率写进了验收单。

三个月里客户陆续补了一批文书,切片策略跟着调了一次。命中率还是按原样报,但那是旧切片下的数字。口径已经变了,数字没跟着变。客户平时不会发现,直到某天他拿一条明明该命中的问题来问,你回一句我们的命中率是验收时那个数,场面就很难看。

处理办法是把基线绑死在知识库快照上。快照摘要变了,旧基线直接判作废,必须重测。

go 复制代码
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 字符串的小函数,都不影响这段的逻辑。

go 复制代码
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))

实跑输出:

rust 复制代码
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 能跑通六段链路,企业级项目要回答的是三个月后它还能不能说得清。


你们的质保期里,第三个月最容易松的是哪一处?评论区说说,我挑典型的单独写一篇。

相关推荐
卷无止境1 小时前
一人公司的全栈选型指南:让 AI Agent 写得快,也让生意跑得稳
后端·python
桃西西呀2 小时前
我把整本接口文档塞进 system prompt,前 3 轮答得准,第 8 轮开始胡说,账单还翻了 4 倍
人工智能·llm·ai编程
天天代码码天天2 小时前
给孩子打印一本“数字连点画”!连出小鱼、火箭,画完还能涂色
人工智能
SL_staff2 小时前
规则联动:给工厂装上‘自动化神经反射弧’——JVS·物联网平台规则引擎实战教程
java·人工智能·物联网
众壹新能源科技3 小时前
逆变器自带无功不够用,加装5MVar SVG后功率因数从0.85回到0.99
运维·人工智能·自动化
king13598745503 小时前
LFM2.5-24B-A2B Mac 本地部署:内存估算、GGUF/MLX 与 Python 预检脚本
人工智能·macos
sbjdhjd3 小时前
从签名重放到 Agent Harness:一堂安全课程如何把前端逆向、MCP 与工程约束串起来
网络安全·ai·github·agent·webshell·mcp·harness
DeeGLMath3 小时前
optimtool最新版加速训练分类任务
人工智能·分类·数据挖掘
在所不辞兄3 小时前
为什么神经网络对对抗扰动和分布偏移往往比较脆弱,而人类感知相对更鲁棒?
人工智能·深度学习·神经网络·机器学习