系统出事时,你要看得见
古董级程序员,大厂出来后一直在创业公司,现在仍在一线写代码、做产品、和 AI 编程工具较劲。更完整的更新会放在微信公众号「字与码」:工作经历、新技术观察、AI 开发实践,以及这些年踩过的坑,都会陆续写在那里。
《AI时代的程序员修养》前面讲了测试。测试回答的是:在已知输入下,系统是不是按预期工作。
但线上系统更麻烦。真实用户的输入不一定在测试里出现,第三方接口不一定稳定,队列可能积压,数据库可能慢,某个新版本可能只在一小部分请求上出错。这个时候,代码写得再漂亮也没用,你必须能看见系统正在发生什么。
第 10 篇讲可观测性。日志、指标、Trace、审计记录和业务埋点,不是上线以后补几行 print。它们是系统设计的一部分,也是 AI 生成代码必须满足的工程边界。
AI 写代码时最容易漏掉“眼睛”
让 AI 写一个接口,它通常会把主流程写出来:
async def create_report(request: ReportRequest) -> ReportResponse:
report = await save_report_request(request)
await enqueue_report_job(report.id)
return ReportResponse(report_id=report.id, status="queued")
这段代码看起来可以跑,但如果线上用户说“我点了生成报告,一直没有结果”,你马上会遇到一串问题:
- 请求有没有进来?
- 数据库有没有写成功?
- 队列有没有投递成功?
- worker 有没有消费?
- 第三方接口有没有超时?
- 失败后有没有重试?
- 重试是不是重复扣费或重复写结果?
- 这个用户是不是命中了某个特殊参数?
- 只有这个用户慢,还是所有用户都慢?
如果系统没有可观测性,这些问题只能靠猜。
AI 生成代码最常见的问题,是把“功能能跑”当成“系统可交付”。真正可交付的服务,必须在出问题时留下足够的证据。
日志、指标、Trace 各看一层
很多人把可观测性理解成日志。日志当然重要,但只靠日志不够。
一个服务至少需要三类视角:
日志 Logs 发生了什么事
指标 Metrics 整体状态变成什么样
链路 Trace 一次请求经过了哪些步骤
日志适合回答“这一次发生了什么”。例如某个 report_id 创建成功、某个第三方接口返回 429、某次重试失败。
指标适合回答“系统整体怎么样”。例如每分钟请求数、错误率、P95 延迟、队列积压、数据库连接池使用率。
Trace 适合回答“一次请求慢在哪里”。例如 API 花了 80ms,数据库写入 20ms,队列投递 10ms,worker 等待 4 分钟,第三方调用 15 秒。
这三者缺一块,排障都会变成半盲。
日志不是字符串,是事件
AI 很容易写出这样的日志:
logger.info(f"create report success: {report.id}")
这比没有日志好,但还不够。它只是给人看的字符串,机器很难稳定分析。
生产系统更应该写结构化日志:
logger.info(
"report_request_created",
extra={
"event": "report_request_created",
"report_id": report.id,
"user_id": request.user_id,
"tool_id": request.tool_id,
"request_id": context.request_id,
"trace_id": context.trace_id,
"status": "queued",
},
)
关键区别在于字段。
字段让日志可以被查询、聚合和关联。你可以查某个 report_id 的完整过程,可以看某个 tool_id 最近失败率,可以把 API 日志和 worker 日志按 trace_id 串起来。
结构化日志里最重要的字段通常有这些:
timestamp 事件时间
level 日志级别
event 稳定事件名
service 服务名
env 环境
request_id 单次入口请求 ID
trace_id 分布式链路 ID
span_id 当前步骤 ID
user_id 用户 ID,注意脱敏和合规
resource_id 业务对象 ID,例如 report_id / order_id
status 成功、失败、跳过、重试
error_code 稳定错误码
duration_ms 当前步骤耗时
这里有一个很实际的判断标准:日志不是给当时写代码的人看的,而是给三个月后值班的人看的。
日志级别要有纪律
AI 生成代码经常乱用日志级别。正常分支打 error,大循环里打 info,异常又被吞掉,只留一句“failed”。
日志级别应该有相对固定的含义:
DEBUG 开发和临时诊断,生产默认不打开或采样
INFO 正常生命周期事件,例如请求创建、任务完成、状态变更
WARN 可恢复异常,例如重试、降级、限流、第三方临时失败
ERROR 当前操作失败,需要被统计和告警
FATAL 进程无法继续工作,通常很少用
这里最容易犯错的是 INFO。如果一个循环要处理几十万个对象,每个对象都打一条 INFO,日志系统会被拖慢,数据库也可能被写垮。被跳过的正常分支,通常应该是计数器或采样日志,而不是逐条刷屏。
给 AI 的要求要写清楚:
不要在高频循环里逐条写 INFO 日志。
对正常 skip 分支使用 metrics 计数。
DEBUG 日志允许采样,队列满时可以丢弃摘要。
ERROR 日志必须包含 error_code、trace_id、resource_id 和异常类型。
这不是吹毛求疵。日志本身也会成为系统负载。
指标看趋势,不看故事
日志告诉你某次请求发生了什么。指标告诉你系统是不是正在变坏。
服务最常用的是 RED 指标:
Rate 请求速率
Errors 错误率
Duration 延迟
如果是资源层,还会看 USE 指标:
Utilization 使用率,例如 CPU、连接池、磁盘
Saturation 饱和度,例如队列积压、线程等待
Errors 错误数
假设有一个报告生成服务,最少应该有这些指标:
http_requests_total{route, method, status}
http_request_duration_seconds_bucket{route, method}
report_requests_total{tool_id, status}
report_jobs_enqueued_total{queue}
report_jobs_processed_total{queue, status}
report_job_duration_seconds_bucket{queue}
report_queue_depth{queue}
third_party_calls_total{provider, status}
third_party_call_duration_seconds_bucket{provider}
db_pool_in_use{database}
注意,指标标签不能乱加。
route、status、provider、tool_id 这种低到中等基数的字段适合做标签。user_id、request_id、report_id 通常不适合做指标标签,因为基数太高,会把时序数据库打爆。
这也是 AI 生成代码需要被检查的地方。它可能会觉得“字段越多越好”,但指标系统最怕无限基数。
直方图比平均值诚实
延迟指标不要只看平均值。
平均 200ms 的接口,可能是大多数请求 50ms,少数请求 10 秒。用户感受到的是尾部延迟,不是平均值。
工程里更常看 P50、P95、P99:
P50 一半请求低于这个耗时
P95 95% 请求低于这个耗时
P99 99% 请求低于这个耗时
如果报告接口 P50 是 120ms,P95 是 2s,P99 是 15s,说明少数请求很慢。这个时候应该顺着 trace 查慢请求,而不是盯着平均值说“看起来还行”。
让 AI 做指标时,要明确:
为外部接口、数据库查询、队列任务和整体请求增加 duration histogram。
不要只记录平均耗时。
告警以错误率、P95/P99、队列积压和饱和度为主。
Trace 是一次请求的时间线
Trace 的核心是把一次请求拆成多个 span。
一个报告生成链路可能长这样:
trace_id=abc
API /reports
├─ validate_request 3ms
├─ insert report_request 18ms
├─ enqueue report_job 9ms
└─ return queued 1ms
worker report_job
├─ load report_request 12ms
├─ call provider A 2400ms
├─ normalize result 35ms
├─ save report_result 22ms
└─ publish notification 8ms
如果没有 Trace,你只能在 API 日志、队列日志、worker 日志之间来回搜。Trace 把这些步骤放到一条时间线上。
这里最关键的是上下文传播。入口请求生成 trace_id,API 写数据库、发消息、调用下游时,都要把这个 ID 传下去。队列任务被 worker 消费时,worker 要继续使用同一个 trace,而不是重新生成一个孤立 trace。
HTTP 里通常通过 header 传:
traceparent: 00-<trace_id>-<span_id>-01
队列里可以放到 message metadata:
{
"job_id": "job_123",
"report_id": "rpt_456",
"trace_id": "abc",
"parent_span_id": "def"
}
如果让 AI 写异步任务,这一点必须写进需求。否则 API 和 worker 会各打一套日志,出了问题只能靠业务 ID 硬拼。
审计记录不是日志的替代品
还有一类数据经常被混在日志里:审计记录。
日志面向工程排障,审计记录面向业务事实。两者不能互相替代。
比如用户修改权限,日志可以记录:
event=permission_update_saved trace_id=...
审计记录应该记录更稳定的业务事实:
{
"actor_id": "user_1",
"action": "permission.grant",
"target_type": "project",
"target_id": "project_9",
"before": {"role": "viewer"},
"after": {"role": "editor"},
"reason": "owner_action",
"created_at": "2026-07-03T08:00:00Z"
}
审计记录要可追溯、可展示、可长期保存。日志可以按保留期清理,审计记录通常不能随便丢。
AI 生成后台管理、权限、扣费、状态变更代码时,必须问一句:这个动作需不需要审计?
业务埋点回答“用户怎么用”
日志和 Trace 主要帮助工程师看系统。业务埋点帮助产品和运营看用户行为。
同一个报告生成服务,可以埋这些事件:
report_create_clicked
report_create_submitted
report_create_succeeded
report_create_failed
report_result_viewed
report_result_exported
每个事件都要有稳定 schema:
{
"event": "report_create_submitted",
"user_id": "user_1",
"session_id": "sess_1",
"tool_id": "tool_x",
"source": "search_result",
"input_size_bucket": "small",
"created_at": "2026-07-03T08:00:00Z"
}
埋点最怕两件事。
一是事件名随手起。今天叫 click_report,明天叫 report_click,后天叫 submit_report_request,最后没人敢用。
二是把所有字段都塞进去。敏感信息、长文本、原始参数、用户输入,如果未经处理就进入埋点系统,后面会非常麻烦。
让 AI 写埋点时,应该先给事件字典,而不是让它自由发挥。
可观测性也有成本
可观测性不是越多越好。
日志太多会增加存储成本,指标标签太多会撑爆时序库,Trace 全量采样会很贵,业务埋点太细会让分析变得混乱。
实际系统里常用这些策略:
日志:ERROR 全量,INFO 控制数量,DEBUG 默认关闭或采样。
指标:核心路径全量聚合,避免高基数字段。
Trace:低流量服务可全量,高流量服务按比例采样,错误请求全量保留。
审计:关键业务动作全量保存,字段稳定。
埋点:围绕漏斗和关键路径设计,不随手埋。
如果某个服务每天只有几千次请求,简单日志加少量指标可能足够。如果每天有几千万次请求,不控制采样和标签,观测系统会比业务系统先倒。
这是 AI 不会天然替你判断的地方。它可以生成接入代码,但成本边界要由人指定。
一个接口的观测验收清单
如果让 AI 写一个生产接口,我会把可观测性要求放进验收清单:
1. 每个入口请求生成或继承 request_id 和 trace_id。
2. 成功、失败、重试、跳过都有稳定 event 名。
3. ERROR 日志包含 error_code、异常类型、trace_id、业务对象 ID。
4. 高频循环不逐条写 INFO。
5. 请求数、错误数、延迟直方图有 metrics。
6. 外部调用、数据库查询、队列投递有独立 span。
7. 异步任务继承上游 trace context。
8. 权限、扣费、状态变更写审计记录。
9. 用户关键行为写业务埋点,事件名和字段来自事件字典。
10. 指标标签没有 user_id、request_id 这类高基数字段。
这份清单比“加日志”有效得多。
给 AI 的提示词可以这样写
可观测性要在实现前写进 prompt:
请实现报告生成接口和异步 worker。
除了业务逻辑,还必须满足以下可观测性要求:
- 使用结构化日志,不要拼接不可查询的长字符串。
- 每个入口请求都有 request_id 和 trace_id。
- API、数据库写入、队列投递、worker 消费、第三方调用分别创建 trace span。
- 队列消息必须携带 trace_id 和 parent_span_id。
- 暴露 RED 指标:请求数、错误数、延迟直方图。
- 暴露队列指标:入队数、消费数、失败数、队列积压、任务耗时。
- ERROR 日志必须包含 error_code、trace_id、report_id、异常类型。
- 高频循环禁止逐条 INFO;正常 skip 用 counter,DEBUG 可采样。
- 用户发起生成、查看结果、导出结果需要写业务埋点。
- 创建、取消、扣费、权限变化需要写审计记录。
实现完成后,请列出可以用哪些查询定位:
1. 某个 report_id 卡在哪里。
2. 最近 15 分钟错误率是否升高。
3. 某个 provider 是否变慢。
4. 队列是否积压。
5. 某个用户的关键操作路径。
这段提示词的目的不是让 AI 多写几行样板代码,而是让它知道:系统上线以后要能被理解。
真正的工程质量,是出事时不慌
平时看一个系统,大家容易看功能、界面、性能。
出事时,真正救命的是证据链。
请求从哪里来,经过哪些模块,在哪一步变慢,哪个依赖失败,影响了多少用户,是否重复执行,是否产生业务损失,是否需要补偿。
这些问题靠日志、指标、Trace、审计记录和埋点一起回答。
AI 会让代码生成更快,也会让“看起来能跑”的服务更多。程序员的责任不是阻止 AI 写代码,而是给它设下工程边界:能测试,能观测,能排障,能交接。
系统迟早会出事。区别只在于,出事时你是打开控制台开始定位,还是坐在黑盒前面猜。
微信公众号
欢迎关注「字与码」
如果这篇文章对你有用,也欢迎在微信里继续关注后续更新。
X / Twitter
关注 @ax2_zicode
更即时的技术观察、新文章提醒和一些短想法会发在 X 上。