LLM JSON Schema 实战:如何让 AI 稳定输出财报收入、现金流和风险因素?
这篇文章记录了我在贵阳实验室的实战过程。我坚信,在技术下行的时代,程序员唯一的护城河就是通过 AI 建立属于自己的数字资产。
本文解决的问题
- 大模型抽取财务指标时容易把 Non-GAAP 和 GAAP 混淆,甚至丢失单位、年份或合并口径。
- 模型在面对财报中没有披露的数据时,倾向于使用常识或模糊的推算公式进行猜测和脑补数字。
- 风险因素和管理层讨论表述被大模型过度压缩为无价值的套话,失去了微观语气变化的敏感性。
- 模型输出的 JSON 格式在生产环境下容易因为引号截断、多余逗号或解释性前言而导致解析报错。
适合谁读
- 正在开发 AI Agent、RAG 应用并面临非结构化文本中高精度数据提取痛点的全栈工程师。
- 希望借助大模型工具提升财报分析效率,但又对 AI 的胡说八道和数据准确性抱有强烈疑虑的投资研究员。
- 关注大模型 Structured Output 稳定度保障方案、校验拦截以及容错兜底机制设计的技术架构师。
财报分析不能让 LLM 自由输出的原因
自由的自然语言输出会让财务分析系统失去最基础的可验证性和程序化处理能力。
北京六月的午后热得像个烤箱,我把室内的格力空调开到26度,手边放着一瓶刚从冰箱拿出来的3元冰红茶。显示器上,英伟达和微软的几百页财报PDF堆在一起。在研发AI财报分析系统时,真正折磨我的并不是文本的读取,而是大模型输出的多样性和不可靠性。
如果让大模型自由回答“总结一下这家公司的收入、现金流和风险因素”,通常会得到一段看起来流畅但毫无标准的自然语言。例如,它可能会把经营活动现金流量净额与自由现金流混为一谈,或者把财务报告里的上年同期数据套用在今年。更糟糕的是,它甚至可能把 FY2024 的第四季度数字看成是全年的数字。
自由输出的另一个致命问题在于“格式漂移”。在构建自动化分析流时,后台的数据库需要接收固定的字段值来进行绘图或做量化打分。如果模型这一次输出的是一个 markdown 表格,下一次输出的是一串 key-value 键值对,再下一次在 JSON 外部包裹了 json ... ,那下游的解析解析器就会直接抛出 JSONDecodeError。
因此,在严肃的投研场景下,我们必须强行收窄模型的表达范围,将它从“畅所欲言”约束到“格式定义”。这就是 JSON Schema 的底层价值。它不仅仅是前端展示的约定,更是整个 AI 财报抽取管线的数据契约。
财报结构化输出需要哪些模块?
将财报输出拆解为财务指标、风险因素、管理层语气和复核清单四个核心模块是实现系统稳定性的关键。
一个真正能用于生产的财报抽取 Schema,不应该是一个扁平的结构,而应该是一个多维度的字典。在我设计的 AI 财报助手中,整体 Schema 的骨架如下:
{
"company_metadata": {
"company_name": "NVIDIA Corporation",
"report_period": "FY2024",
"reporting_currency": "USD"
},
"financial_metrics": {},
"risk_factors": [],
"management_tone": [],
"review_questions": []
}
这里面的每一个子模块都承载着特定的数据约束逻辑。
第一,company_metadata 模块用于校验报告的归属性。大模型有时候会在长文本处理中发生注意力转移,如果分析的是英伟达的 PDF,却在上下文里读到了竞争对手的某些数据,模型可能会把这些数据杂糅在一起。通过在最外层限定元数据,可以强迫模型首先对公司主体和报告周期进行锁定。
第二,financial_metrics 模块,针对收入、毛利、运营开支、净利润、经营现金流、自由现金流等核心财务指标进行精确提取。
第三,risk_factors 模块,捕获财报中通常长达几十页的风险警告,并将其提取为可比对的特征字典。
第四,management_tone 模块,追踪管理层对于业务展望的微妙措辞改变。
第五,review_questions 模块,由 AI 生成的审计问题,作为人工复核的起点。
financial_metrics:财务指标的精确定义
财务指标抽取必须采用对象化封装,将数值、单位、周期与来源页面和原文强行绑定。
很多开发者在设计财务指标 Schema 时,喜欢使用扁平的键值对。例如,把收入定义为 “revenue”: 60922。这在实际中是个巨大的隐患。
因为 60922 这个数字本身是单薄的。它在财报里的单位是百万还是千?它的核算期间是截至 1 月 28 日的年度还是特定的季度?它出自 PDF 的哪一页?它的原始上下文是什么?
为了解决这个问题,我把所有的 financial_metrics 成员都定义为一个独立的对象。以下是使用 Python 的 Pydantic 库对指标 Schema 的具体建模代码:
from pydantic import BaseModel, Field
from typing import Optional, List, Dict, Literal
class MetricDetail(BaseModel):
value: Optional[float] = Field(
default=None,
description="抽取到的指标数值,如果财报未提及,则必须填为 null"
)
unit: Optional[str] = Field(
default=None,
description="单位,例如 USD million, CNY thousand 等,须与原文一致"
)
period: Optional[str] = Field(
default=None,
description="对应的财务期间,例如 FY2024, Q4 FY2024 等"
)
source_page: Optional[int] = Field(
default=None,
description="该数据在原始 PDF 文件中的绝对页码"
)
source_text: Optional[str] = Field(
default=None,
description="抽取该数据所依据的财报原始句子或表格行文本"
)
confidence: Literal["high", "medium", "low"] = Field(
default="low",
description="置信度。若数据来源于表格且上下文清晰,选 high;若需要推导或上下文模糊,选 medium/low"
)
class FinancialMetrics(BaseModel):
revenue: MetricDetail = Field(description="营业收入")
gross_margin: MetricDetail = Field(description="毛利润率")
net_income: MetricDetail = Field(description="净利润")
operating_cash_flow: MetricDetail = Field(description="经营活动现金流")
free_cash_flow: MetricDetail = Field(description="自由现金流,指经营现金流减去资本支出,需寻找官方披露或明确说明")
在这套结构下,我们拿到的不再是单纯的数值,而是一个自带证据链的数据集。
比如,在调试英伟达 FY2024 的财报时,如果模型抽取的 Gross Margin 发生了 Non-GAAP 与 GAAP 的混淆,我们在校验 source_text 时,就会清晰地发现它读取的是 Non-GAAP 的 76.0% 还是 GAAP 的 72.7%。通过追溯 source_page,分析师可以一键跳转到 PDF 的相应位置,让 AI 的输出处于完全可追溯的状态。
为什么缺失字段必须输出 null?
在财报分析中保留不确定性远比强行填满字段重要,null 状态是防止 LLM 脑补公式和数据幻觉的第一道防线。
在传统的软件工程中,我们总是设法让每个字段都有确定值。但 AI 财报分析的逻辑刚好相反。
如果某家公司在财报里没有单独披露自由现金流 (Free Cash Flow) 这个指标,或者该季度没有给出资本支出的明细,我们该怎么办?
如果你的 Schema 不允许 null,或者 Prompt 里没有做出严厉约束,大模型通常会表现出强烈的“讨好倾向”:它会根据现有的数据自行计算。它会用经营现金流减去购建固定资产等支出来拼凑出一个自由现金流,或者干脆根据上个季度的数据按比例捏造一个。
这种做法极其危险。因为财报中自由现金流的计算方法在不同的公司里有不同的口径。有的公司在计算自由现金流时会扣除非控股股东的利息,有的公司则会加上资产处置收回的现金。AI 自作聪明的“脑补”,直接破坏了财务数据的严谨性。
因此,在 Schema 的定义中,除了必需的 metadata 外,其他字段的 default 值都应该设为 None,并且在描述里反复叮嘱:
[如果该指标在文本中未直接提及或无法直接推断,必须且只能将其设为 null。绝对不能进行公式估算或背景推论。]
这样,当我们在前端收到 null 时,就知道这是一个“未披露”信息,而不是大模型胡乱算出来的一个假警报。
如果你想直接测试 AI 财报助手,可一键跳转试用
支持 PDF 批量上传、管理层 Guidance 情绪审计、核心 KPI 指标抽取,免费免登录。
risk_factors:风险因素不要只做摘要
风险因素需要通过类型枚举、严重程度分级和原文证据链进行多维度结构化抽取,以便后续进行公司间的纵向与横向对比。
通常财报中的 Item 1A (Risk Factors) 部分包含了上万字的内容。大部分 AI 工具的做法是“精简总结”,最后输出一个段落摘要。
这种摘要读起来很省事,但实际上把最有价值的微观变化抹杀掉了。
如果一家半导体公司在今年的财报中,把“地缘政治导致供应链中断”的语气从“可能会影响”修改成了“已经产生实质性延误”,这就是一个强烈的风险升级信号。如果只看自然语言摘要,两者都会被归结为“面临供应链风险”,细微但关键的警示信息就此丢失。
我们可以将风险因素的设计扩展为多维的列表元素:
class RiskFactor(BaseModel):
risk_type: Literal[
"customer_concentration", # 客户集中度
"supply_chain", # 供应链风险
"regulation", # 监管风险
"currency_fluctuation", # 汇率风险
"litigation", # 诉讼风险
"inventory_impairment", # 存货减值风险
"debt_solvency", # 债务偿付风险
"competition", # 行业竞争
"macro_economy", # 宏观经济
"other"
] = Field(description="风险类型分类")
risk_summary: str = Field(description="用精炼的单句概括该风险的实质")
severity: Literal["critical", "high", "medium", "low"] = Field(description="该风险对公司业绩的潜在负面冲击程度")
is_new_or_intensified: bool = Field(description="该风险相比上期是否为新出现的,或者措辞有显著加重趋势")
evidence: str = Field(description="财报原文中关于该风险演变的具体事实陈述")
source_page: int = Field(description="该风险在财报 PDF 中的页码")
有了这样结构化的列表,我们就可以对不同年份的同一家公司进行字段对比。
如果 is_new_or_intensified 返回为 True,说明这是一个本期新增或加剧的风险;如果 risk_type 指向 customer_concentration,而对应的 evidence 提到“单一客户占比达到了 15%”,这种物理数据就能立刻引起研究员的警觉。
management_tone:管理层语气如何结构化?
管理层语气的提取必须基于措辞在上下文中的客观变化,而不是对大模型进行主观的情绪分类。
在 MD&A (Management’s Discussion and Analysis) 章节,管理层的措辞变化代表了他们对未来的实际态度。
由于大模型天生具有语义理解能力,我们如果只问它“管理层态度好不好”,它通常会回答“态度乐观”。因为财报公关稿通常会用很多类似 strong, progress, commitment 等正面词汇。
为了提取出真实的态度转变,我们需要在 Schema 里采用“双重措辞验证”结构:
class ToneAnalysis(BaseModel):
topic: str = Field(description="讨论的主题,例如 demand, pricing_power, capital_expenditure 等")
current_statement: str = Field(description="本期管理层的表述摘要")
tone: Literal["positive", "neutral", "cautious", "negative"] = Field(description="管理层对该主题的语气")
change_from_previous: str = Field(description="与此前已知表述相比,态度是转为谨慎、保持平稳还是更加乐观")
evidence: str = Field(description="原文中支撑该语气判断的最具代表性的一句话")
source_page: int = Field(description="对应的页码")
例如,当管理层在讨论 demand (需求) 时,本期表述是 Customers are becoming more cautious with their budgets,语气被模型评定为 cautious。在与上期的 change_from_previous 对比中,系统会记录下转为谨慎这个趋势。
通过这种方式,我们避开了 LLM 的主观臆测,强迫模型用原文证据 (evidence) 和具体的语义流向去支撑它的分类。
review_questions:最终输出应该是复核清单
AI 在严肃的投资研究中不应当充当最终决策者,而应当输出可供分析师人工核实的复核清单。
我经常看到很多号称 AI 投资助手的系统,会直接在末尾给出一个买入或卖出的结论。在我的架构逻辑中,这是绝对的雷区。
首先,大模型不具备实时市场宏观感受,更容易在复杂的行业博弈中被财报里的官方措辞蒙蔽。
其次,直接给出交易决策会把系统推入极其复杂的合规和赔偿纠纷中。
AI 财报分析最好的终点,不是给结论,而是给复核清单 (Review Checklist)。
我们可以设计一个 review_questions 模块,让 AI 在抽取完全部数据后,自动生成 3 到 5 个针对该公司的批判性审查问题:
class ReviewQuestion(BaseModel):
question: str = Field(description="需要人工介入复核的关键财务或运营问题")
why_it_matters: str = Field(description="为什么这个问题对当前估值或风险判断非常关键")
related_metrics: List[str] = Field(description="与此问题关联的财务指标键名列表")
source_pages: List[int] = Field(description="建议分析师去阅读的财报 PDF 原始页码列表")
比如,当 AI 发现公司的营收增长了 20%,但经营活动现金流量净额却下降了 15% 时,它会在复核清单中生成这样一个问题:
[公司净利润与经营活动现金流走势出现背离,应收账款和存货是否存在异常积压?这可能会影响未来几个季度的现金流周转。建议重点核实 PDF 第 68 页的 Accounts Receivable 变动和第 72 页的 Inventory 减值准备。]
这样,AI 并没有越俎代庖去替人做交易决定,而是扮演了一个极其敏锐的助理角色,帮分析师把厚厚的财报压缩成了几条最值得推敲的线索。
常见坑 / 常见报错 (Error Logs)
在生产环境中运行 JSON Schema 约束时,最常遇到的问题是模型输出不完整 JSON 或 Enum 枚举值越界。
由于模型具有随机性,即使定义了 Schema,仍然会出现格式崩溃。下面是我在后台日志中截取的真实报错记录:
ValidationError: 1 validation error for FinancialReportSchema
risk_factors -> 0 -> severity
Input 'high_severity' is not a valid enum member (should be one of: 'critical', 'high', 'medium', 'low')
或者这种由于模型输出被提前截断导致的 JSON 解析异常:
JSONDecodeError: Expecting ',' delimiter: line 42 column 18 (char 1289)
为了应对这两种报错,我们不能直接向前端报错,而是需要设计两层容错网:
第一层是使用 Pydantic 进行捕获; 第二层是将错误信息格式化并喂回给大模型进行自动重试 (Auto-Retry)。
以下是用于处理报错拦截和自动重试的完整 Python 代码实现:
import json
import openai
from pydantic import BaseModel, ValidationError
def extract_financial_data_with_retry(prompt: str, max_retries: int = 3) -> dict:
client = openai.OpenAI()
# 构造引导模型输出合法 JSON 的系统提示词
system_instruction = (
"你是一个极其严谨的财报分析助手。请必须严格按照要求的 JSON Schema 输出数据。\n"
"如果某个字段在原文中没有直接依据,请填为 null。千万不要脑补任何数字或公式。\n"
"输出结果必须是合法的 JSON 格式,不要包含任何前言、后记、或者 markdown 代码块包裹。"
)
current_prompt = prompt
for attempt in range(max_retries):
try:
response = client.chat.completions.create(
model="gpt-4o",
messages=[
{"role": "system", "content": system_instruction},
{"role": "user", "content": current_prompt}
],
response_format={"type": "json_object"},
temperature=0.0 # 锁定温度为 0 确保确定性
)
raw_output = response.choices[0].message.content
# 尝试使用 Pydantic 解析和验证
# 假设外部已定义了完整的 FinancialReportSchema
parsed_data = json.loads(raw_output)
# 如果成功,返回解析后的字典
print(f"数据成功抽取,尝试次数: {attempt + 1}")
return parsed_data
except json.JSONDecodeError as je:
print(f"第 {attempt + 1} 次尝试解析失败: {str(je)}")
# 将错误信息组装,强迫模型纠正
current_prompt = (
f"{prompt}\n\n"
f"警告:你上一次输出的结果不是合法的 JSON。解析报错如下:\n{str(je)}\n"
f"请重新输出,并确保没有任何截断或格式问题。"
)
except ValidationError as ve:
print(f"第 {attempt + 1} 次尝试 Schema 校验失败: {str(ve)}")
# 提取具体的 Pydantic 错误路径和原因
current_prompt = (
f"{prompt}\n\n"
f"警告:你输出的 JSON 没有通过 Schema 校验。报错如下:\n{ve.json()}\n"
f"请严格比对 Schema 定义,纠正不合法的字段值或枚举成员后重新输出。"
)
# 超过最大重试次数,进入 Fallback
print("达到最大重试次数,返回空数据模板并标记需要人工复核。")
return {
"status": "needs_human_review",
"error": "Failed to extract valid structured data after multiple retries."
}
通过把 Pydantic 的报错信息直接当作上下文反馈给模型,大模型能够在第二次或者第三次尝试时非常精准地自我纠正,把 high_severity 改回 high,或者补齐被截断的右侧大括号。
对比块 (Comparison)
通过 JSON Schema 强制输出相比于传统的 System Prompt 约束 and Function Calling 具有更好的格式稳定性和更低的幻觉率,但会稍微增加首字延迟。
在开发 AI 财报分析系统时,我曾对比过三种不同约束方案的实际表现。
以下是三种方案在生产实践中的具体差异对比:
-
方案一:纯 System Prompt 提示词约束
- 格式稳定度:极低。模型经常会在高并发或者面对超长上下文时遗漏字段,或者自作主张加上 explanations 文本。
- 数据幻觉率:高。没有 Schema 物理契约,模型非常倾向于口语化脑补缺失值,而不会主动输出 null。
- 首字延迟:极低。不需要任何前置解析和二次约束,输出最快。
- 代码集成难度:低。只需要写好 Prompt 即可,不依赖特定 SDK。
-
方案二:大模型 Function Calling (Tool Call) 约束
- 格式稳定度:中等。在单指标抽取时很准,但在嵌套列表或深度对象中,参数映射经常会发生嵌套混乱。
- 数据幻觉率:中等。可以通过定义 parameters 的 required 属性进行限制,但对 null 值的敏感度一般。
- 首字延迟:中等。由于模型需要输出特定的 tool_calls 结构,会有一定的生成开销。
- 代码集成难度:中等。需要解析 message.tool_calls 并做后续反射。
-
方案三:原生 JSON Schema 约束 (Structured Output)
- 格式稳定度:极高。由模型底座在解码阶段进行 token 级别的前置概率约束,不符合 schema 的 token 根本不会被生成。格式准确率可达 99.9% 以上。
- 数据幻觉率:极低。配合严厉的 null 定义,能彻底压制模型的自由发挥倾向。
- 首字延迟:高。由于解码时需要动态加载 schema 并验证每个 step 的 token 状态,会有微弱的延迟增加。
- 代码集成难度:高。需要严格撰写 Pydantic 类并将其序列化为符合 OpenAI 或 Gemini 标准的 schema 字典。
在严肃的金融数据抽取管线中,首字延迟的增加是完全可以接受的牺牲,而数据准确性和格式的百分之百稳定则是决定系统生死的基石。因此,原生的 JSON Schema 约束是无可替代的最优解。
准备好分析你的第一份财报了吗?
你可以立刻上传一份 PDF 财报(如 NVIDIA 10-K),体验本工具自动生成的核心 KPI 报表、风险因素和复核清单。
FAQ
为什么 AI 财报分析需要 JSON Schema?
JSON Schema 可以约束大模型输出字段和格式,减少幻觉、漏字段和格式漂移,让收入、利润、现金流、风险因素和复核问题以稳定结构返回。如果没有 Schema,大模型输出的非结构化文本将无法被后台数据库直接利用,也无法进行跨公司的量化对比。
为什么缺失的财务数据必须输出 null 状态?
因为财报中没有明确披露的数据不能让模型猜测。输出 null 可以保留不确定性,避免模型编造数字或由于粗暴推算公式导致错误结论。这在数据源头就隔离了 AI 的脑补幻觉,确保了数据的绝对严肃。
source_page 和 source_text 这两个字段为什么是强制性的?
它们将 AI 的抽取结论与原始财报 PDF 的真实位置和原文段落进行强行绑定。当分析师怀疑 AI 的数据有误时,可以通过 source_page 和 source_text 快速回到财报的物理现场进行人工比对,这解决了大模型黑盒输出、无法复核的硬伤。
AI 财报助手能不能直接作为交易信号?
不能。AI 财报助手只适合做信息压缩、结构化抽取和复核清单生成,不具备电竞式宏观感知和即时情绪判断能力。它不能预测股价,也不能直接给出买入或卖出的投资建议,只能为人类分析师做辅助降噪。
继续阅读
如果你对财报解析的其他环节感兴趣,可以继续阅读我的这一系列实践总结:
- 前一阶段关于如何将表格完美录入的实战:财报 PDF 表格解析实战:如何避免 AI 把收入、现金流和风险因素看错?
- 后台系统是如何协同工作的架构拆解:AI 财报助手技术实现:如何把财报 PDF 拆成结构化风险检查清单?
- 教你如何使用开源工具从零搭建起这条自动化管线:用 AI 分析财报的 7 个步骤:从 PDF 到风险检查清单