随笔-都没报错但结果是错的

都没报错,但结果是错的——记最近踩的几个坑

最近在维护一个内部用的简历筛选系统。技术栈很朴素: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

原因有两层:

  1. pymysql 只有在 execute(sql, args) 传了 args 的时候才做 % 格式化。我没传参数,%% 就原样发给 MySQL 了;
  2. 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 的截图过来,正式库里 resumeresume_actionresume_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    撤销

带来三个好处:

  1. 可追溯。半年后看到一个 50 分,一眼能看出这是人给的不是模型给的,模型原本打 31;
  2. 可撤销。人工分清掉就自动回到 AI 分,不需要"记住原来是多少";
  3. 重算不打架。重新评分时 final_score 会被清空重算,但 manual_score 不在清空列表里,人工判断不会被一次重跑抹掉。

第 3 点是关键。如果当初直接覆盖 final_score,那么任何一次"按新条件重新评分"都会把 HR 的人工判断冲掉,而且冲掉了还没人知道。

抽象出来:机器算的值和人改的值,分开存,并且记录来源。 只要一个字段可能同时被"自动流程"和"人工"写入,就该考虑拆成两列,否则早晚会遇到"谁覆盖了谁"的扯皮。


小结

这次的坑,除了栈溢出那个,全都不报错。

回头看它们有个很一致的形状:都有一层"容错"把问题吞掉了。

  • 岗位匹配不上 → fallback 到 _default,不吭声
  • %% → 在 MySQL 里是合法转义,不报语法错
  • object_exists 查询异常 → 我自己 except 掉返回 False
  • 连错库 → 库名一样,连接成功,表不在而已

容错设计本身没问题,甚至是必要的——不能因为 HR 写错一个岗位名就让整个系统崩掉。但容错和"静默"是两回事。降级要留痕,哪怕只是一行 warning,也能把排查时间从两小时缩到两分钟。

还有一条是关于验证的:别用产生数据的那套逻辑去验证数据。 换个语言、换条路径、换个人算一遍,才叫验证。我那条返回 0 的校验 SQL,差点让 579 条脏数据就那么过去了。

posted @ 2026-08-06 15:47  -鱼七-  阅读(3)  评论(0)    收藏  举报