Agent 说「你有这个能力」,凭什么是它说了算?
这篇讲的是怎么让简历里每一句关于你的事实,都能指回一个具体地址;指不回去的,不许出现在产物里。
一、它解决什么问题
论文里写「研究表明 X」,后面必须跟一个 [12],指到具体哪篇论文。
指不出来,那句话就是编的。
简历也一样。Agent 写「具备跨团队协作能力」,这个结论应该能指回:它来自你哪一段经历。
如果指不回去,那就是它自己加的。
二、不用它会怎样
面试官盯着简历问:
「你写你负责了 Kubernetes 集群迁移,讲讲迁移过程中最大的坑?」
你心里一片空白。因为那段是模型写的,你只负责点了「生成」。
这份简历没有帮你,它在替你挖坑——而且是在你最没法临场发挥的地方。
所以证据不是洁癖,它决定这份产物是资产还是负债。
三、代码是怎么做的
1. 给简历里每一条事实一个地址
packages/resume-agent/src/validation/evidence.ts
buildEvidenceIndex(resume) 递归遍历 resume.content,把每个非空字符串变成一条 Evidence:
{
id: 'candidate.content.work[0].summary',
path: 'candidate.content.work[0].summary',
section: 'work',
text: '...'
}它跳过 computed 和 layouts——那些是渲染配置,不是事实。
上传的文件也会变成证据:artifactEvidence(artifacts) 把它们变成 artifact.<id>。
于是「候选人材料」不再是一坨文本,而是一张带地址的表。
2. JD 也要拆成原子需求
JobSpec.requirements[] 里每条需求有自己的 id、text、keywords、importance(must-have 或其它)、category。
prompt 里把形状写死了:
Keep each requirement text concise (one capability, ideally under 120 characters)
and provide 1 to 5 canonical keywords that can be matched literally against resume evidence.一条需求 = 一个能力,不超过 120 字符,1–5 个关键词。
为什么?因为「熟悉分布式系统并有大流量经验」这种复合需求,要么整条命中、要么整条没有——「分布式系统会,大流量没做过」这个中间状态被丢掉了,而它是简历最需要说清的部分。
3. 逐条对,并且过滤掉「水词」
packages/resume-agent/src/matching/match.ts
buildMatchReport(jobSpec, evidence) 对每条需求找出命中的证据,打三档状态:
| 命中证据数 | status |
|---|---|
| 0 条 | missing |
| 1 条 | partial |
| ≥2 条 | matched |
score = matched 条数 ÷ 总条数。
这里最值得注意的是 GENERIC_KEYWORD_TOKENS,一张被排除的词表:
'a', 'an', 'and', 'build', 'building', 'data', 'engineer', 'engineering',
'experience', 'for', 'in', 'of', 'on', 'skill', 'skills', 'strong',
'system', 'systems', 'team', 'the', 'to', 'using', 'with'不排除会怎样?JD 写「strong engineering team」,简历里随便有句「team」就算命中。
一个所有简历都能命中的关键词,等于没有筛选能力。
匹配前还会 normalize:转小写、标点换成空格、合并连续空白。
4. 指不回原文的引用,不许进产物
packages/resume-agent/src/workflow/agent.ts
草拟阶段模型要返回 selectedEvidenceIds。拿回来立刻检查:
const invalidEvidenceId = draftResponse.selectedEvidenceIds.find(
(id) => !evidenceIds.has(id)
)
if (invalidEvidenceId) {
throw new Error(
`Generated draft referenced unknown evidence: ${invalidEvidenceId}`
)
}模型只能从证据表里挑 ID;挑一个不存在的,整次 draft 直接失败。
这是整套设计里最硬的一条:编了跑不通。
如果模型一条 ID 都没给?回退到确定性匹配算出来的结果:
selectedEvidenceIds: draftResponse.selectedEvidenceIds.length
? draftResponse.selectedEvidenceIds
: matchReport.selectedEvidenceIds,回退方向是有序的:模型的语义判断优先,没判断就用关键词匹配兜底。两种情况下,列表里的每个 ID 都真实存在于证据表里。
5. 覆盖率不是「模型说多少」,是把产物重新量一遍
packages/resume-agent/src/transparency/quality.ts
buildQualityReport(jobSpec, resume) 拿到的是最终简历,然后:
- 对最终简历重建证据索引
- 重新跑一次
buildMatchReport - 用这个结果算
requirementCoverage/mustHaveCoverage/keywordCoverage
也就是说,覆盖率不是模型自评填的,是把产物拿回来重新量了一遍。
警告是稳定的 code,不是自由文本:
| warning code | 触发条件 |
|---|---|
missing_must_have_requirements | 有 must-have 需求没被覆盖 |
missing_job_keywords | 有 JD 关键词不在简历里 |
sparse_resume_content | 证据条目少于 5 条 |
稳定的 code 才能被断言引用——上一篇那六条断言,靠的就是这里的 code 稳定。
6. 透明度:把推理路径摊开
一次 Run 的产物里,除了简历本身还有:
| 字段 | 它回答的问题 |
|---|---|
jobSpec | Agent 认为这份 JD 在要什么 |
matchReport | 每条需求配到了哪些证据 ID |
diff | 相对原始简历改了哪些字段(added / removed / changed / reordered,带 before / after) |
quality | 覆盖率与 warning code |
trace | 每个阶段的 started / completed / failed 和关键计数 |
trace 里的一条长这样:
{
name: 'match_evidence',
status: 'completed',
at: '2026-09-16T...',
metadata: { score: 0.5, matched: 3, missing: 3 }
}注意 metadata 的类型被限制成 string | number | boolean。
透明度不等于泄露。 你可以告诉用户「匹配上 3 条、漏了 3 条」,但不需要把简历正文塞进运行日志。
diff 还有个容易忽略的细节:数组类 section(work、projects)不按下标比较,而按身份键匹配。否则调换两段经历的顺序,diff 就会报出一堆 changed——看起来像改了内容,其实只是换了位置。一个会让用户误判的 diff,等于没有 diff。
四、面试官会追问什么
Q:关键词匹配会不会漏掉同义表达?JD 写 Kubernetes,简历写 K8s。
会,这是当前实现明确的短板。所以我要求模型拆需求时给出 canonical keywords,把同义词归一到可字面匹配的形式;模型的
selectedEvidenceIds会覆盖确定性匹配的结果——语义判断交给模型,确定性匹配只做兜底和复算。修法是 embedding 检索,我还没做。
Q:为什么覆盖率要重新算一遍,而不是信模型?
因为覆盖率是我用来判断质量的指标。如果由被测对象自己填,它就没有判断力了。重新算的成本很低——遍历一遍简历、跑一次关键词匹配——但它把「指标」和「被指标」分开了。
Q:透明度和隐私冲突吗?
冲突点在「展示多少原文」。结构信息我全展示(阶段、计数、字段路径),trace 的 metadata 限制成
string | number | boolean。但diff里的 before / after 是原文——那不是日志,是用户审阅自己简历改动的通道。把自己的数据给用户看是功能,把用户数据写进运行日志是泄露。
五、一句话版本
让模型说的每一句关于你的事实,都能指回一个具体地址;指不回去的,就不许出现在简历里。
六、自测
buildEvidenceIndex为什么跳过computed和layouts?- 如果把
GENERIC_KEYWORD_TOKENS去掉,匹配结果会怎么变坏? - 模型返回了不存在的 evidence ID 时,系统是忽略它还是直接报错?为什么?
- 覆盖率为什么要重新算一遍?