Skip to content

从 PDF 到可信 JSON:AI 简历 CLI 的需求拆解与工程实现

约 4256 字大约 14 分钟

PythonCLIAI工程实践

2026-08-26

最近看到一个简历分析工具的需求:读取 PDF 简历、调用 AI 提取信息、再根据岗位描述给出匹配评分。

字面上只有三项功能,如果直接开工,写一个能跑的版本并不费事。

我当时没有马上建目录。解析和提取是什么关系?PDF 是否默认走 OCR?parse 要不要把整份简历铺在终端里?没有 API Key 时怎样演示?模型给出的分数靠什么解释?这些问题都没有现成答案,放着不管,后面一定会以返工的方式出现。

于是这个项目从一轮挺长的需求讨论开始。问题一条条定下来以后,我把结论整理成开发计划,再交给 Codex 按阶段执行。中间继续检查实现、调整细节、补测试,最后才有了现在的 resume-cli。这篇记录主要写这段过程,也顺便解释几个关键设计。

项目和实际效果可以从下面俩个入口查看:

写代码前,先把模糊的地方问完

最开始讨论得最多的,是三个命令之间的关系。

parseextract 都会读取简历,看上去有些重复。继续拆开以后就清楚了:parse 解决 PDF 文本层读取问题,全程留在本地;extract 复用解析结果,再把完整文本交给模型生成固定 JSON。score 也会解析 PDF,不过它直接使用完整简历和 JD,不先经过 extract,因为姓名、技能、学历这些摘要字段不足以判断项目深度、上线职责和量化结果。

其他问题也在这一阶段逐渐收敛:

讨论的问题最后采用的约定
普通 PDF 是否需要 OCR有文本层就直接解析;扫描件检测后提示先做 OCR
parse 默认输出什么只显示文件信息和有限预览,--full--output 才给全文
Mock 是否可以不传文件不可以;Mock 只替换 AI,PDF 和 JD 仍走完整校验
评分是否交给模型计算模型找证据并判断状态,Python 按固定公式计算
是否拆成四个 Agent当前流程一次结构化调用就够,不增加重复请求和汇总误差
Key、模型和 Base URL 怎么配置Key 用环境变量或 .env,模型可临时覆盖,Base URL 固定
Docker、uv、Makefile 怎么分工uv 负责 Python 安装和依赖,Docker 负责隔离运行,Makefile 只做开发快捷入口

这些讨论最后写进了仓库的 docs/implementation-plan.md。计划里不仅列功能,也写了公共命令、数据结构、评分权重、退出码、隐私边界、测试范围、提交顺序和发布标准。

这份计划也调整过几次。比如最初的评分更偏技能,讨论后改成技能 30%、项目与交付经验 40%、教育 10%、持续成长 20%;overall_score 从简单平均改成固定加权;公开 JSON 保持扁平,内部模型则使用嵌套结构,避免维护两份分数来源。

等这些细节稳定下来,Codex 才按照计划依次完成项目骨架、PDF 解析、Kimi Provider、评分、Mock、测试、CI、文档和 Release。开发过程中遇到新问题,就回到计划和测试里补上,而不是只改到当前命令能继续运行。

三个命令各自管一段

CLI 是 Command-Line Interface,也就是命令行界面。安装完成后,Windows 的 PowerShell、macOS 的 Terminal 和 Linux Shell 都可以直接执行 resume-cli

项目保留了三条职责很清楚的命令:

resume-cli parse examples/sample-resume.pdf
resume-cli extract examples/sample-resume.pdf --mock
resume-cli score examples/sample-resume.pdf --jd examples/sample-jd.txt --mock

它们共用同一个 PDF 解析服务,拿到文本以后的走向不同。

parse 默认只显示路径、页数、字符数和前面一小段内容。这样可以快速确认文件能否解析,又不会把一整份简历刷满终端。需要完整文本时再加 --full,或者用 --output parsed.txt 保存。

extract 读取的是完整文本,不受预览长度影响。真实模式调用 Kimi,Mock 模式使用离线 Provider,两者最后都要通过同一份 Pydantic Schema。缺失字段只能是 null 或空数组,不能根据常识补出一段个人经历。

score 同样使用完整简历,并要求传入非空的 UTF-8 JD。这个命令没有先调用 extract,少走一步调用是一方面,更实际的原因是结构化摘要会损失大量项目上下文。

PDF 能不能读,看的是文本层

“本地 PDF”这个说法容易让人联想到 OCR。实际判断方式很简单:在阅读器里能不能选中并复制正文。

Word、WPS、浏览器或者在线简历系统正常导出的 PDF,通常已经带有文本层,pypdf 可以直接读取。扫描仪生成的文件、手机拍照、截图拼接成的 PDF 通常只有图片,需要先经过 OCR。双栏、表格和特殊字体即使有文本层,也可能出现阅读顺序不理想的问题。

v1 没有内置 OCR。项目会识别空文本、疑似扫描件和加密文件,并告诉用户下一步怎么处理。OCR 涉及语言模型、系统依赖、版面恢复和准确率,仓促接进去反而会让一个稳定的解析命令变得难以预测。

公开示例也专门做成了带文本层的中文 PDF。生成脚本使用中文字体,测试会逐行核对提取结果;我还把 PDF 渲染成图片检查过实际排版,避免出现程序能读、打开却是方框或乱码的情况。

LLM 提供语义判断,代码验证并计算

如果提示模型“请给这份简历打分”,它通常会返回一个挺像样的数字,可这个数字很难解释,也不容易复现。项目把这一步拆成了证据分析和数值计算。

这里可以说得更具体一点:LLM 提供语义判断,判定的是四个维度下共 14 个内部标准,并准备好判断理由和简历/JD 原文短引用。它不会直接决定技能分、经验分或综合分,也不能修改评分项和权重。

程序拿到结果后,会重新检查该项要求的引用是否确实存在于输入文件。每个非 missing 项都需要简历原文证据,直接涉及岗位要求的标准还需要 JD 原文证据;伪造、改写或无法定位的必要引用不计分。通过校验以后,matchedpartialmissing 才分别映射为 100%、50%、0%,再进入固定公式。代码没有重复做一次语义分析,它检查的是模型给出的判断能不能追溯到原文。

overall_score = round_half_up(
  skill_score      × 30% +
  experience_score × 40% +
  education_score  × 10% +
  growth_score     × 20%
)

仓库内置的中文示例正好可以把整个过程算一遍。Mock 模式使用固定数据模拟 LLM 的逐项判断,真实模式则由 Kimi 返回这些状态和依据;后面的证据校验和公式完全相同。

技能维度的三项都判为 matched

内部标准状态简历/JD 依据维度内得分
必备技能覆盖 50%matchedPython、TypeScript、FastAPI / 熟练使用 Python 和 TypeScript50
项目应用深度 30%matched基于 Kimi 兼容接口开发简历分析服务 / 大模型 API 集成和结构化输出30
加分/相邻技能 20%matchedDocker、GitHub Actions / Docker 和 CI/CD20

所以 skill_score = 50 + 30 + 20 = 100

项目与交付经验有一项只得到 partial

内部标准状态简历/JD 依据维度内得分
相关性 25%matched生产级人才服务 API / 生产级 API 交付25
所有权 20%matched架构设计与上线交付 / 从实现、上线到运行维护的完整责任20
复杂度/设计 20%matched设计原文证据校验和确定性评分机制 / 系统设计经验20
上线运维迭代 20%partial写明上线交付,但没有充分说明后续运维责任10
量化结果 15%matched将处理耗时降低 40% / 可量化的产品效果或性能优化成果15

因此 experience_score = 25 + 20 + 20 + 10 + 15 = 90。这里的 10 分来自 20 × 50%,也是第一道面试追问会继续核实上线后运维职责的原因。

教育与基础的两项均为 matched

内部标准状态简历/JD 依据维度内得分
学历/专业 70%matched计算机科学与技术,本科 / 计算机相关专业本科70
基础/证书/等价学习 30%matched计算机科学与技术 / 具备同等基础30

所以 education_score = 70 + 30 = 100

持续成长维度的计算是:

内部标准状态简历/JD 依据维度内得分
能力演进 25%partial只写明当前高级工程师经历,没有展开早期成长轨迹12.5
持续输出 30%matched每月更新技术博客 / 技术写作经验30
维护质量 25%matched自 2023 年持续维护 Resume Toolkit / 开源项目长期维护25
外部影响 20%matched1200 个 GitHub Star / 开源项目长期维护20

四项相加是 87.5,按 round half up 得到 growth_score = 88。最后再计算综合分:

overall_score = round_half_up(
  100 × 30% +
   90 × 40% +
  100 × 10% +
   88 × 20%
)
= round_half_up(93.6)
= 94

这样回看输出中的 94,它就不再是模型随口给出的数字。模型负责阅读语义、逐项判断和引用证据,程序负责拒绝无法核实的依据,并把通过校验的状态换算成四个维度分数和综合分。

项目与交付经验占 40%,里面继续看相关性、主导性、复杂度、上线运维和量化结果。持续成长占 20%,关注能力演进、长期输出、维护质量和外部影响。GitHub Star 只会影响外部影响这个小项,不会让整个成长维度直接满分。

结果里的 2~5 个问题不参与得分。简历只写了“负责上线”,没有说明上线后具体承担哪些运维工作,工具就把它留成一道追问。这里保留一点“不确定”,比自动补齐经历稳妥得多。

没有为四个维度各建一个 Agent

讨论评分时,我也考虑过四个分析 Agent 加一个汇总 Agent。后来把调用过程和失败点列了一遍,这套设计对当前项目没有明显帮助。

四个 Agent 会重复读取同一份简历和 JD,Token、延迟和出错机会都会增加。综合分本身是一条固定公式,再让第五个 Agent 汇总,结果反而可能漂移。当前任务没有循环规划、工具自治、人工审批和持久状态,一次严格结构化调用更合适。

代码里仍然保留了 Provider 协议。Kimi Provider 负责真实请求,Mock Provider 负责离线演示,CLI 和评分器只依赖协议。以后若增加联网核验、并行检索或人工复核,可以在这个边界后面扩展,现阶段用不到 LangChain 或 LangGraph。

Mock 留住了文件和业务链路

公开仓库不能假设每个读者都有 Moonshot Key。--mock 让安装后的人可以立即跑通示例,不过它只跳过外部模型请求。

PDF 仍要存在并且可解析,JD 仍要存在、非空且编码正确,证据校验、评分和 JSON 序列化也照常执行。仓库里的中文虚构简历和 JD 对应一套稳定 fixture,适合 CI 和录屏;换成其他文件时,Mock 会给出保守结果,并在 stderr 提醒这不是语义分析。

这种安排也方便排查问题。Mock 正常、真实模式失败时,可以优先检查 Key、网络和模型响应;两种模式都失败,就回到 PDF、JD 或本地业务链路继续定位。

uv、Docker 和 Makefile 各管一件事

用户从固定版本安装最省事:

uv tool install --python 3.12 git+https://github.com/Mars13333/resume-cli.git@v1.0.1
resume-cli --help

uv tool 会创建隔离的 Python 环境,并把命令放进用户 PATH。源码贡献者则使用 uv syncuv run。这两种方式都支持 Windows、macOS 和 Linux。

Docker 提供另一套运行环境。宿主机不需要安装 Python 或 uv,只需挂载本地文件并运行镜像。容器使用非 root 用户,示例 Mock 可以断网执行,.env 也不会进入镜像。

Makefile 只给开发者缩短命令,例如 make checkmake buildmake docker-smoke。Windows 没有 Make 也不影响使用,README 里保留了对应的 uv 和 Docker 原始命令。

代码之外也需要验收

功能跑通以后,仓库还经历了几轮检查。

stdout 只放正文或 JSON,日志和警告放 stderr,方便继续接管道;文件输出先写临时文件,再原子替换目标;详细日志不记录简历、JD 和 API Key。错误按参数、PDF、AI 服务和写入失败分配稳定退出码。

测试覆盖了正常流程,也包括伪 PDF、损坏文件、加密文件、扫描件、非法 JD 编码、API 认证、限流、超时、模型截断、非法 JSON、伪造证据、Windows 中文输出和舍入边界。目前共有 61 个测试,覆盖率超过 94%。GitHub Actions 分别在 Python 3.11/Linux、3.12/Windows、3.13/macOS 上运行;Linux 还会构建 wheel、源码包和 Docker 镜像。

提交记录也按照计划分开保留:项目初始化、PDF 解析、Kimi 提取、证据评分、Mock、测试、CI、文档和发布各自独立。以后查看某次修改时,可以从提交信息知道它解决了什么,不必在一个巨大提交里猜。

文档同样参与验收。README 要说清安装、配置、命令、评分、OCR、Docker、Makefile、隐私和限制;重要取舍单独放进 ADR;演示脚本规定录屏顺序和敏感信息检查。原始需求留在本地,公开仓库和 Blog 只写通用需求,不出现真实需求来源、公司名称或个人简历。

总结

回头翻开发记录,最有用的部分还是最早那串问题。parse 默认打印多少内容、Mock 要不要校验文件、总分怎样舍入、Docker 是否替代 uv,这些都不算复杂技术,可每一个都会影响命令接口、测试和文档。如果等代码写完再决定,改动会散到整个项目里。

现在这套流程已经比较固定了:先把需求里含糊的词圈出来,和 AI 把使用场景、异常边界、数据结构与交付方式聊清楚;把结论写成可以逐项验收的计划;再让编码工具执行,人负责看取舍、检查结果和继续追问。代码风格、提交记录、测试范围和文档质量,都在这个过程中一起形成。

项目目前发布到了 v1.0.1。它没有 OCR、DOCX、在线事实核验和桌面界面,评分也只能作为辅助信息。后面如果继续做,我会先看实际使用中最常出现的 PDF 和 JD 问题,再决定下一步,而不会为了让功能列表更长就把边界重新打开。