项目演示用结构化数据 grounding 替代 naive RAG, hallucination 可客观衡量,eval-driven 开发流程效果提升7倍。
如何将 Andrew Ng 的"AI 工程技能图谱"转化为真实、可衡量、可验证的软件——以及为何用结构化数据做根基比朴素 RAG 提升 7 倍。
Andrew Ng 最近分享了他的 AI 工程技能图谱:构建和部署 AI 应用的六项技能——LLM 基础、用数据为模型提供根基、构建智能体系统、评估驱动的开发、生产环境运行,以及机器学习基础。
"区分一个出色的 AI 系统构建者最重要的特质,是能否驱动一个严格的评估/错误分析循环。"
我想构建一个演示这个论点的项目,而不是仅仅描述它。我选择的方案:
一个失败可以被客观衡量的领域。 一个回答 SEC 10-K 文件相关问题的金融研究智能体。一个幻觉出的数字就是错误的——因为有已知的真实答案作为基准。
评估集先于智能体。 从真实文件中提取的真实答案,而不是凭空想象。
每个架构决策都针对同一个评估集来衡量。
最终成果 —— 10-K-able —— 是一个尽职调查研究智能体,能用可验证、有引用来源的数字回答诸如"苹果 2025 财年研发费用是多少?"这样的问题。本文是工程层面的深度解析。
使用相同的 24 题评估集、相同的真实 LLM 端点,测试三种架构:
(作为参考,原始完整智能体工具循环在同一测试集上得分 16.7%——这个负面结果促使我们简化出了下面的方案。)
两个发现,都是真实的:
用结构化数据做根基才是关键。 朴素向量 RAG 得分 8.3%,因为模型不断回答"10-K 中未说明"——数字根本没在检索到的文本块中浮现。在财务报表上添加语义层后,准确率提升了 7 倍(最新实际运行达到 58.3%)。
完整智能体循环在这类结构化查询上表现不如混合方案。 这是一个真实的负面结果:对于答案存在于单行表格中的问题,工具调用循环增加了步骤却没有增加信号。随后我据此采取了行动——将数值问题直接路由到混合工作流(37.5%)——这正是评估循环闭环运作的体现。
┌─────────────────────────────────────────────┐
│ User │
│ ask "What was Apple's R&D…" │
└──────────────────────┬──────────────────────┘
│
┌──────────────────────▼──────────────────────┐
│ CLI (cli.py) │
│ ask · eval · ingest · redteam · drift │
│ traces · scorecard · final-scorecard │
└──────────────┬───────────────┬──────────────┘
│ │
┌────────────────────────▼─────┐ ┌─────▼─────────────────────────┐
│ ENGINE (engine.py) │ │ EVAL HARNESS (eval_harness)│
│ baseline RAG │ hybrid │ agent │ │ scorecard · error clusters │
│ + QuestionRouter (sklearn) │ │ LLM-judge · verify pass │
└───────┬───────────┬───────────┘ └──────┬──────────────────────┘
│ │ │
┌─────────────▼───┐ ┌────▼──────────┐ ┌───────▼─────────┐
│ VECTOR STORE │ │ CORPUS │ │ TRACE STORE │
│ chunks.json │ │ corpus.json │ │ traces.jsonl │
│ embeddings.npy │ │ records[] │ │ (cost/latency) │
└─────────────┬───┘ └────┬──────────┘ └─────────────────┘
│ │
┌─────────────▼───────────▼─────────────────────────────┐
│ INGEST (ingest.py) │
│ sec_edgar.py → tables.py → corpus + vector store │
│ + generate_eval_set.py (ground truth from filings) │
└──────────────────────┬────────────────────────────────┘
│
┌──────▼──────┐ ┌────────────────────┐
│ SEC EDGAR │ │ LLM (llm.py) │
│ (raw 10-K) │ │ OpenAI-compatible │
└─────────────┘ └────────────────────┘
根基故事始于原材料。现代 10-K 以 HTML 格式提供,内嵌 XBRL:每个财务数字都标有 us-gaap:* 名称、规模和 contextRef。这是一个馈赠——如果你知道去哪里找,这些数字是机器可读的。
# sec_edgar.py — fetch a company's latest 10-K
def find_latest_10k(self, cik: str, years: int = 1):
subs = self.get_submissions(cik)
recent = subs["filings"]["recent"]
# filter form == "10-K", take the latest accession
...
# tables.py — classify a statement table by its us-gaap tags
def classify_by_tags(block: str) -> str:
tags = re.findall(r'name="(us-gaap:[A-Za-z]+)"', block)
# us-gaap:Revenues / GrossProfit / OperatingIncomeLoss → income_statement
# us-gaap:Assets / Liabilities / StockholdersEquity → balance_sheet
# us-gaap:NetCashProvidedByUsedInOperatingActivities → cash_flow
这结果是整个构建过程中最难的部分,也是最具启发性的部分。我的第一个提取方案——找到类似"合并利润表"的标题,然后切取后续区域——在一个 100 万字符的文档上失败了,因为标题文本首先出现在目录中,所以我提取的是目录而不是报表。修复方法:完全忽略标题,扫描每个 <table>,根据其内部的内联 XBRL 标签对表格进行分类,并在接受之前验证表格"看起来像报表"(美元符号、用逗号分隔的大数字、括号中的负数)。
第二个 bug 更加隐蔽:年份标题行(["September 27,2025", "September 28,2024", ...])没有标签列,但数据行有——再加上交错的 $ 列标记。按列索引将年份与值映射导致所有内容都错位了(R&D FY2024 显示的是 FY2025 的值)。修复方法是位置配对:
# tables.py — pair years to values in column order, skipping $ markers
years_in_order = [yr for _, yr in sorted(year_positions, key=lambda x: x[0])]
for row in rows[1:]:
values = [_parse_number(c) for c in row[1:] if _parse_number(c) is not None]
for yr, val in zip(years_in_order, values):
records.append(StatementRecord(...))
修复后,苹果的数字与真实 10-K 完全吻合:FY2025 研发 = 34,550(百万),FY2024 = 31,370,FY2023 = 29,915。这就是"将混乱文档转化为 LLM 可用输入"的技能,而且有收据可查。
Ng 的技能图谱说 RAG 配向量搜索是"早期尝试"——根基技术菜单已经扩展。本项目在同一个评估集上比较了该菜单上的两个方案:
仅向量(基线): 将 10-K 文本分块,用 all-MiniLM-L6-v2 嵌入,通过余弦相似度检索,将块填充到提示中。结果:8.3%。浮出水面的块往往是样板风险因素,而不是利润表。模型知道数字在文档中,但找不到。
混合方案(结构化数据之上的语义层): 在摄取期间将财务报表提取为类型化记录(statement_type、line_item、fiscal_year、value),然后让引擎对公司和明细项目进行精确查找,同时进行向量检索以获取上下文,最后进行验证通过:
# engine.py — hybrid answer path
def answer_fn(question: dict) -> str:
records = [r for r in corpus.records_for(company)
if _match_line_item(r["line_item"], question)]
excerpts = store.search(question, k=6)
prompt = CONTEXT_TEMPLATE.format(
records=_format_records(records),
excerpts=_format_excerpts(excerpts),
)
answer = llm.chat([...])
return _verify_pass(answer, prompt, question, llm)
结果:58.3%(最新实际运行)。结构化查找用真实数字而非 prose 回答数值问题。这是该项目最重要的单一架构发现,它直接对应 Ng 的观点:"在结构化数据(如客户记录)之上的语义层"是与向量搜索截然不同的根基技术。
评估工具是刻意无聊且确定性的——这正是重点。每个问题都有预期答案和检查类型:
# eval_set.py — deterministic checks, no LLM needed
def check_numeric(answer: str, expected: str, tol_frac: float = 0.02) -> bool:
a, e = extract_number(answer), extract_number(expected)
return abs(a - e) <= max(tol_frac * abs(e), 1e6)
def verify(question: dict, answer: str) -> bool:
return {"numeric": check_numeric, "keyword": check_keyword,
"text": check_text}[question["check"]](answer, question["answer"])
每一次失败都被归入一个错误桶(error bucket)——numeric_error、factual_error、hallucination_avoidance_false_negative、analysis_missing——使得错误分析是系统性的,而非轶事式的。
这个循环立即发挥了作用。开发过程中,我用一个上下文阅读测试模型验证了测试工具链——一个替代品,它"读取"提示中的记录并返回正确的数字,这样无需消耗 API 额度就能检查工具链是否正常。它暴露了一个真实的 bug:
verify-pass 提示告诉模型:"如果任何数字没有被上下文支持,则更正它或替换为'10-K 中未声明'。"模型抓住了这个退路,将正确答案也回退成了"10-K 中未声明"。
修复方案:使 verify 指令保持中立——如果被支持则原样返回答案,仅更正不支持的部分,绝不暗示备用方案。这是一种"评估你的评估"的缩影:测试工具链发现了一个提示中的缺陷,修复方案被一条回归测试所保护。
# engine.py — 修复后的 verify 指令(无备用方案措辞)
"Verify each factual claim and every number in the previous answer against "
"the context above. If the previous answer is fully supported, return it "
"unchanged. If a claim or number is not supported, correct that specific "
"part using the context. Return only the final verified answer."
Ng 将智能体系统描述为一个频谱,从工作流(预定义的 LLM 调用序列)到智能体工具链(模型自行决定下一步)。我没有二选一,而是同时构建了两者,让评估结果来决定:
# engine.py — 智能体的小型工具注册表
TOOL_SEARCH = "search" # 对文档摘录的向量搜索
TOOL_LOOKUP = "lookup" # 按科目精确查找财务记录
TOOL_CALC = "calculate" # 计算比率/百分比
TOOL_VERIFY = "verify" # 根据上下文核验最终答案
Agent 循环通过问题类型来选择工具(通过一个小型 scikit-learn LogisticRegression 路由器,基于人工构建的特征),在出错时优雅降级到混合路径,并运行最终验证环节。
评估结果非常明确:在结构化数字查询上,完整 Agent 循环(16.7%)表现不如纯混合工作流(45.8%)。一个包含多次 LLM 调用的工具调用循环,是的正确答案需要多步推理时的正确工具;对于"这个表格里的数字是多少",它只是在燃烧 token 和步骤。这正是 Ng 所说的"何时用代码、何时用 LLM、何时用智能体、何时用工作流"的判断——而且这里是实证结果,不是猜测。
随后我根据这个负面结果采取了行动:Agent(simplified=True) 将数字/派生问题直接路由到经过测量的混合工作流(记录查找 + 验证),只为趋势/分析问题保留工具循环,因为这些场景下多步推理能增加信号。这挽回了我大部分差距——简化后的 Agent 在同一评估集上得分 37.5%,混合路径在 verify-pass 修复后达到 58.3%。由数据驱动的简化正是评估循环闭合闭环的方式。
生产层设计得很薄,但它是真实可用的:
Trace 存储——每次查询都记录日志(延迟、成本、模型、通过/失败)到 JSONL 文件;traces 命令汇总 p95 延迟和成本。
真实成本核算——LLM 客户端从每个 API 响应中捕获 token 使用量(prompt/completion/cached),并将估算成本接入每次评估运行和追踪。最近一次真实评估:24 个问题约花 $0.005。生产声明背后是真实数字,不是占位符。
漂移检测——评估回归漂移(最新准确率 vs 阈值)加上输入漂移(线上问题分布是否仍与评估集相似?)。
红队测试套件——五个对抗探测(提示注入、数据泄露、反幻觉、越界)作为常设套件运行。一个诚实的结果:当前模型尚未 100% 鲁棒——它有时会遵从"仅回复 '1,000,000'"的注入(通过率约 0.8–1.0)。这是红队测试存在的意义——暴露真实发现,而非隐藏。
LLM-as-a-judge 校准——一项 judge 校准研究将盲测 LLM judge 与确定性检查进行对比。Judge 高估了(25% 一致率,78.6% 假阳性率)——证明该 judge 尚不可作为主要信号。
CI 评估门禁——一个 GitHub Action,在每次 PR 上运行单元测试 + 评估套件,并在准确率回归到阈值以下时失败。
# .github/workflows/eval_gate.yml(略)
jobs:
eval-gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pip install uv && uv sync
- run: uv run --with pytest pytest tests/ -q
- run: uv run python -m tenkable eval --mode hybrid --no-judge
- name: Fail on regression
run: |
ACC=$(...latest eval accuracy...)
python -c "import sys; sys.exit(0 if float('$ACC') >= 0.30 else 1)"
一个经过训练的模型运行在应用内部:QuestionRouter 是一个基于四个手工构建特征(是否数字?原因?趋势?涉及金额?)的 scikit-learn LogisticRegression,它将每个问题路由到一种策略。它刻意做得很小——演示目的是一个经过训练的路由器可以击败人工编码规则,而且误差分析和偏差/方差思维贯穿始终。
(日志中的一条注记:路由器目前是在评估集本身上训练的,这存在数据泄露;真实版本需要一个留出的拆分。诚实的局限性是评估优先文化的一部分。)
uv sync
cp .env.example .env # 设置 LLM_API_KEY(OpenAI 兼容端点)
uv run python -m tenkable ingest # 抓取 + 解析 + 索引
uv run python -m tenkable ask "What was Microsoft's 2025 revenue?" --company MSFT --mode hybrid
uv run python -m tenkable eval --mode hybrid # 根据评估集打分
bash scripts/real_evals.sh # 一次性运行所有
10-K-able 只使用真实 LLM——.env 中需要有效的 LLM_API_KEY;没有模拟 provider。你得到的每一个数字都是由配置的 OpenAI 兼容模型针对真实申报文件生成的。
大部分原始路线图现已上线并被测量:
✅ 为结构化查询简化智能体——已完成(Agent(simplified=True)),挽回了大部分差距(Agent 16.7% → 37.5%;混合 45.8% → 58.3%)。
✅ 基于真实 token 的成本核算——已完成:Usage 从每个 API 响应中捕获 prompt/completion/cached token + 估算成本,接入评估运行和追踪(最近一次真实评估:24 个问题约 $0.005)。
✅ LLM-as-a-judge 校准研究——已完成(tenkable judge-calibration):盲测 judge 存在校准偏差(25% 一致率,78.6% 假阳性率),因此尚不可作为主要信号。
仍待解决,且评估循环持续指向此处:
修复路由器数据泄露——QuestionRouter 目前在评估集本身上训练;需要一个留出拆分才能成为诚实的 ML 结果。
强化红队缺口——模型有时会遵从"仅回复 '1,000,000'"的注入;更严格的系统提示或 verify 式护栏是下一次迭代的方向。
在一个标注切片上微调一个小模型(LoRA),与前沿模型在同一评估集上进行比较。
给工程师的要点
接地是一项菜单,不是单一工具。向量搜索得到 8.3%;在同一数据上的语义层在最新一次真实运行中得到 58.3%。要测量,不要假设。
评估集就是契约。每一项贡献都根据它来评判。这就是 AI 开发是系统性的而非随机的原因——这正是 Ng 的原话。
负面结果也是发现——而且它们应该改变你的设计。Agent 循环败给工作流(16.7% vs 45.8%)告诉了我不应该在哪里投入;据此采取行动(将数字问题路由到混合路径)正是将 Agent 带到 37.5% 的原因。
测试工具链本身。一个上下文阅读测试模型在真正消耗 API 调用之前就捕获了一个真实的提示 bug。
真实模型,诚实数字。项目只运行真实 LLM——没有模拟 provider——并报告实时红队通过率,即使它不是 100%。
完整代码在 10-K-able 仓库中——README、构建日志(journal/build_journal.md)、记分卡(SCORECARD.md)和测试均包含在内。Fork 它,运行评估,试着超越 58.3%——这个循环会告诉你方向在哪里。