随笔-都没报错但结果是错的
都没报错,但结果是错的——记最近踩的几个坑
最近在维护一个内部用的简历筛选系统。技术栈很朴素:Python + Flask + MySQL,前端就是一个单文件的 HTML,没上框架。核心功能是把 BOSS 直聘下载下来的 PDF 简历批量解析入库,然后丢给两个大模型交叉打分,人工再复核。
这两周陆陆续续加了几个功能,也踩了一串坑。回头看,这些坑有个共同点:程序一次都没崩过。编译通过、SQL 执行成功、页面正常打开,就是结果不对。这种坑比 traceback 难查多了,记一下。
一、加分项写了却不生效,因为岗位名对不上
用户跟我说:提示词里明明写了"有 A 公司、B 公司工作经验属于加分项",但这两家公司出来的候选人分数还是很低。他的第一反应是——是不是得写清楚"加几分"?
听起来挺合理,但我先去看了下代码。
岗位评分条件是一份 YAML,HR 自己维护:
软件应用支撑岗:
加分项:
- 有 A 公司、B 公司的工作经验
- 接触过智慧共享财务平台
岗位是从文件名里切出来的。BOSS 直聘导出的文件名长这样:
【软件应用支撑_杭州_6-9K】某某_1年.pdf
取 【】 里第一个下划线之前的部分,得到 软件应用支撑。
而 YAML 里的 key 是 软件应用支撑岗。
差一个"岗"字。
再看取条件的逻辑:
def get_position_criteria(position):
data, err = load_criteria()
if position and position in data:
return data[position], err
if isinstance(data.get('_default'), dict):
return data['_default'], err # 匹配不上就用兜底
return _BUILTIN_DEFAULT, err
精确匹配失败,静静地落到 _default。而 _default 里根本没有那两家公司的加分项——也就是说,这条加分项从头到尾就没进过提示词,模型压根没看见过。
写个脚本验证一下:
文件: 【软件应用支撑_杭州_6-9K】某某_1年.pdf
抽出岗位: '软件应用支撑'
匹配到的条件: _default(兜底)
该条件是否含目标公司加分项: False
另一份更离谱,文件名是 【软件应用支撑(浙江全省)_杭州_5-9K】...,岗位切出来是 软件应用支撑(浙江全省),一样匹配不上。
修法不难,加一层容错匹配:归一化的时候把括号后缀和结尾的"岗/岗位"去掉,再按「完全相等 > 互为前缀」的优先级去找,同级取归一化后最长的那个(尽量精确)。
def _normalize_pos(s):
s = re.sub(r'[((【\[].*?[))】\]]', '', str(s or '').strip()) # 去掉(浙江全省)这类后缀
s = re.sub(r'(岗位|岗)$', '', s.strip()) # 去掉结尾的"岗"
return s.strip()
改完拿那两份简历实际跑了一遍双模型:
| 简历 | 修复前 | 修复后 |
|---|---|---|
| 候选人甲 | 55 | 61 |
| 候选人乙 | 52 | 58 |
分数上去了,更关键的是模型输出的"亮点"里开始出现"有 A 公司实习经验"——说明加分项这次真的被读到了。
但分数也就到 60 出头,为什么
因为这份 YAML 里还有一条一票否决:"不要实习生"。
而这两位在 A 公司的经历,恰恰都是实习。
一边说"这两家公司的人是加分项",一边说"实习生一票否决",规则自己跟自己打架。模型就在中间反复横跳,给个不高不低的分。
这事让我想明白一个道理:给大模型写规则和写代码不一样,代码里两条规则冲突会报错或者短路,提示词里冲突了它不会告诉你,它会自己"和稀泥",给一个看起来合理、实际上谁也不满足的结果。所以提示词也需要 review,尤其是"加分项"和"一票否决"这种正负规则并存的时候,得检查有没有互相矛盾。
抽出来的教训
真正坑人的不是那个 if,是静默降级。
匹配不上 → 用兜底 → 不打日志 → 一切正常运行 → 只是结果一直是错的。用户能看到的现象只有"分数偏低",离根因隔了十万八千里,很自然就会往"提示词写得不够具体"上想。
说来惭愧,我加完容错匹配之后,这段逻辑依然是静默的——哪天 HR 又加个新岗位名没对上,还是会悄没声地用 _default。真正该补的是这一句:
log.warning(f'岗位「{position}」未匹配到任何配置,已使用 _default 兜底')
凡是写 fallback 的地方,都该留一句日志。兜底是为了让程序不崩,不是为了让错误没有声音。
二、%% 把日期写成了字面量,而我的校验也是错的
这个坑更蠢,但更值得记,因为它连着踩了两次。
需求是把 resume.file_path 从本机绝对路径改成对象存储的路径。写了条 UPDATE:
cur.execute("""UPDATE resume
SET file_path = CONCAT('rpa/job/', DATE_FORMAT(resume_date,'%%Y-%%m-%%d'), '/', file_name)""")
%% 是我下意识写的——写惯了 pymysql,怕 % 被当成占位符。执行成功,579 行受影响。
然后我打印了几条样例:
rpa/job/%Y-%m-%d/【财务助理_杭州_5-6K】某某_一年以内.pdf
日期是字面的 %Y-%m-%d。
原因有两层:
- pymysql 只有在
execute(sql, args)传了 args 的时候才做%格式化。我没传参数,%%就原样发给 MySQL 了; - MySQL 的
DATE_FORMAT里,%%表示一个字面的百分号。所以%%Y到了 MySQL 那儿是%Y这两个字符,不是"年份"。
真正让我后背发凉的是下一步。我写了条校验,想确认全都改对了:
SELECT COUNT(*) FROM resume
WHERE file_path <> CONCAT('rpa/job/', DATE_FORMAT(resume_date,'%%Y-%%m-%%d'), '/', file_name)
返回 0。"不一致 0 条,完美。"
——废话,我拿同一个错误的表达式去校验同一批用它写出来的数据,当然一致。
校验逻辑绝对不能复用被校验对象的那份逻辑。 这是同源错误,两边一起错的时候,校验只会给你一个虚假的安心。
改法上,正确的写法根本不需要 DATE_FORMAT——MySQL 的 DATE 类型隐式转字符串就是 YYYY-MM-DD:
UPDATE resume SET file_path = CONCAT('rpa/job/', resume_date, '/', file_name)
校验则换成用 Python 独立算一遍期望值,再逐条比对:
for r in rows:
expect = cos_key(r['resume_date'].strftime('%Y-%m-%d'), r['file_name'])
if r['file_path'] != expect:
bad.append(r['id'])
顺带说个后续:我想再确认一下有没有残留的字面 %Y,随手写了个
SELECT COUNT(*) FROM resume WHERE file_path LIKE 'rpa/job/%%Y%%'
返回 6 条。心里一紧,仔细一看——% 在 LIKE 里是通配符,%%Y%% 就是"任意内容 + Y + 任意内容",匹配到的是文件名里含大写字母 Y 的 6 条(有个候选人英文名叫 JensenYu)。虚惊一场。
同一个 %,在 Python 格式化、在 DATE_FORMAT、在 LIKE 里是三种完全不同的含义。写 SQL 拼接的时候真得停下来想一秒。
三、全局替换把函数定义自己也替换了
前端是个单文件 HTML,几千行。我要给卡片操作加上"跨日期也能用"的能力,于是抽了个取数函数:
const getResume = id => state.resumes.find(x=>x.id===id) || state.searchResults.find(x=>x.id===id);
然后把散落在各处的 state.resumes.find(x=>x.id===id) 全局替换成 getResume(id)。十几处,一把梭。
打开页面,点一下按钮:
RangeError: Maximum call stack size exceeded
at getResume (:739:23)
at getResume (:739:29)
at getResume (:739:29)
...
因为定义那一行自己也匹配了替换规则:
const getResume = id => getResume(id) || state.searchResults.find(x=>x.id===id);
完美的自我递归。
这个坑的教训特别朴素:全局替换之前,先想想"定义处"是不是也符合匹配条件。提取函数这类重构尤其容易中招,因为新函数的函数体,长得就跟你要替换的那个模式一模一样。
四、改了模板不生效,Flask 把它缓存了
接上一条。我改完 getResume,刷新页面,还是栈溢出。
以为是浏览器缓存,加了个 ?v=2,还是。硬刷新,还是。
最后在控制台敲了一句:
getResume.toString()
// "id => getResume(id) || state.searchResults.find(x=>x.id===id)"
打出来的还是旧代码。文件我明明改了,Read 出来也是新的。
原因是这个项目跑的是 app.run(debug=False),而 Jinja2 在非 debug 模式下会把模板编译结果缓存在进程内存里。改 templates/*.html 不重启进程,改动就是不生效——和浏览器缓存一点关系都没有,怎么强制刷新都没用。
判断方法也简单:如果连"页面里的 JS 函数源码"都还是旧的,那就不是浏览器的问题,是服务端根本没吐出新内容。 这个区分点值得记住,能省不少瞎折腾的时间。
顺便,这也是为什么这类项目最好在开发时开 debug=True(自带模板自动重载),部署时再关掉。
五、两个同名的数据库,害我下了个错误结论
要把上面那个 file_path 的改动同步到正式库。我看了下配置,连过去,在正式库里找 resume 表——一张都没有。
于是我很笃定地跟用户汇报:"正式库里根本没部署这套系统,数据只在测试库里。"
结果人家甩了张 DBeaver 的截图过来,正式库里 resume、resume_action、resume_ai_eval 一应俱全,然后说:你用 secrets_local.py 里注释掉的那段连接信息试试。
打开一看:
# ---------------------开发环境配置---------------------
os.environ.setdefault('DB_HOST', '10.x.x.x') # 内网
os.environ.setdefault('DB_NAME', 'cx')
# ---------------------正式环境配置---------------------
# os.environ.setdefault('DB_HOST', 'xxx.sql.tencentcdb.com') # 腾讯云
# os.environ.setdefault('DB_NAME', 'myapp-oa')
内网那台 MySQL 上,恰好也有一个叫 myapp-oa 的库(是同名的另一套 OA 系统),里面自然没有简历相关的表。我连的是内网,查的是那个同名库,然后对着它得出了"正式库没有表"的结论。
真正的正式库在腾讯云 CynosDB 上,另一台服务器,另一个端口,另一个账号。
判断"我现在连的是哪个环境",要看的是 host:port + 账号,不是库名。 库名在不同实例上重复太常见了,尤其是那种从正式库 dump 出来改个名当测试的场景。
后来在正式库上重做了一遍:715 条,全量校验一致,再拿每一条的路径去对象存储做了一次存在性核对,缺失 0 条。这次才算踏实。
六、两个后来觉得做对了的设计
坑记完了,也有两个当时想了想、后来证明省了不少事的地方。
幂等:以目标状态为准,而不是记录自己做过什么
要把历史简历批量传到对象存储。最直觉的做法是维护一张"已上传清单"——数据库加张表,或者本地存个 JSON。
我没这么干,改成每次传之前问一句对象存储:"这个 key 你有没有?"
def _exists(key):
try:
return bool(_client.object_exists(Bucket=_bucket, Key=key))
except Exception:
return False # 查不到就当没有,宁可重传也不漏传
有就跳过,没有就传。
好处是这个脚本变得完全可重入:跑一半 Ctrl+C 了、网断了、换台机器再跑一遍、同一天触发好几次,都不会重复上传,也不会漏。代价只是一次 HEAD 请求。
后来实际验证:715 条里 715 条已存在,全部跳过,一个都没重传。
抽象一下就是——幂等最省心的实现方式,是让"要不要做"这个判断依赖目标端的真实状态,而不是依赖自己维护的一份记录。 自己维护的记录一旦和现实不同步(清单丢了、多台机器各存各的、传成功了但记录没写上),就会出错,而且这种错很难发现。
人工修正的值,不要覆盖机器算出来的值
有个需求是:AI 给某些人打分偏低(比如特殊情况、或者简历写得差但人确实合适),HR 想点一下就把分数定到 50。
最省事的做法是直接 UPDATE ... SET final_score = 50。我没这么做,加了一列:
ALTER TABLE resume_ai_eval
ADD COLUMN manual_score INT DEFAULT NULL COMMENT '人工确认分数(覆盖AI综合分,NULL=未覆盖)';
对外的"生效分"是 manual_score ?? final_score,AI 原始分原封不动留着,前端同时显示:
(50) 不推荐 水分高 [人工确认]
✓ 已人工确认 50 分 AI 原始分 31 撤销
带来三个好处:
- 可追溯。半年后看到一个 50 分,一眼能看出这是人给的不是模型给的,模型原本打 31;
- 可撤销。人工分清掉就自动回到 AI 分,不需要"记住原来是多少";
- 重算不打架。重新评分时
final_score会被清空重算,但manual_score不在清空列表里,人工判断不会被一次重跑抹掉。
第 3 点是关键。如果当初直接覆盖 final_score,那么任何一次"按新条件重新评分"都会把 HR 的人工判断冲掉,而且冲掉了还没人知道。
抽象出来:机器算的值和人改的值,分开存,并且记录来源。 只要一个字段可能同时被"自动流程"和"人工"写入,就该考虑拆成两列,否则早晚会遇到"谁覆盖了谁"的扯皮。
小结
这次的坑,除了栈溢出那个,全都不报错。
回头看它们有个很一致的形状:都有一层"容错"把问题吞掉了。
- 岗位匹配不上 → fallback 到
_default,不吭声 %%→ 在 MySQL 里是合法转义,不报语法错object_exists查询异常 → 我自己except掉返回 False- 连错库 → 库名一样,连接成功,表不在而已
容错设计本身没问题,甚至是必要的——不能因为 HR 写错一个岗位名就让整个系统崩掉。但容错和"静默"是两回事。降级要留痕,哪怕只是一行 warning,也能把排查时间从两小时缩到两分钟。
还有一条是关于验证的:别用产生数据的那套逻辑去验证数据。 换个语言、换条路径、换个人算一遍,才叫验证。我那条返回 0 的校验 SQL,差点让 579 条脏数据就那么过去了。
浙公网安备 33010602011771号