前段时间我在搞一个金融研报自动生成的项目,用 LangGraph 配合 qwen3-max 搭的。想法挺美好,让 Agent 自动去抓三家公司的三年财务报表,算指标,画图表,最后组装成一份完整研报。
结果呢,光是载入三年的资产负债表、利润表、现金流量表,再聊几轮分析对话,上下文就直接炸了。
qwen3-max 给了 256K 的上下文窗口,听着挺大。但你知道一家公司一年的资产负债表有多少个字段吗?少说上百个。三年三张表,再加财务指标,再算上 Agent 自己的推理过程和工具调用记录,Token 消耗是指数级往上窜的。
我当时的心态就是,这玩意根本跑不通。
不是因为模型不够聪明,是因为上下文管理这个事情,靠自己在 LangGraph 里写节点间的文件传递逻辑,实在太痛苦了。每个节点处理完得把结果写到本地文件,下一个节点只读文件路径,上下文里只留一个摘要。听起来简单,但实际操作中各种边界情况能把你逼疯。
后来我发现了 Claude Agent SDK。
这玩意是 Claude Code CLI 的库形式封装,完整继承了 Claude Code 的 Agent Loop、内置工具、Skills、还有最关键的一个东西,五级渐进式上下文压缩策略。
我当时就在想,如果这个压缩策略真的好用,那我之前在 LangGraph 里手搓的那套文件传递机制,是不是就可以全扔了?
于是有了 FinScribe 这个项目。

今天这篇文章,我就来拆解一下 FinScribe 是怎么搭建的,从架构设计到代码实现,踩了什么坑,最后效果怎么样。
可能有些想法还不成熟,但我已经毫无保留地分享了。
先聊聊上下文为什么炸
很多朋友可能没有处理过金融数据的 Agent 项目,对「上下文溢出」这个事情没有体感。我给你算笔账。
一家 A 股上市公司,AKShare 接口返回的资产负债表,大概有 100 到 150 个字段。你用 ak.stock_balance_sheet_by_yearly_em(symbol='SH600600') 拉一下青岛啤酒的数据,返回的 DataFrame 有 36 行(每年一行),每行 150+ 列。
python
# 拉取青岛啤酒的资产负债表数据
import akshare as ak
# symbol 参数需要带市场前缀,SH 代表上交所,SZ 代表深交所
df = ak.stock_balance_sheet_by_yearly_em(symbol='SH600600')
# 返回的 DataFrame 结构:
# 行:每年一条记录(通常有十几年的历史数据)
# 列:REPORT_DATE, TOTAL_ASSETS, TOTAL_LIABILITIES 等 150+ 个字段
# 单张表转成文本大约 15,000-20,000 tokens
print(f"行数: {len(df)}, 列数: {len(df.columns)}")
一张表两万个 Token,听着还行对吧。但你得算三张表,资产负债表、利润表、现金流量表,每张都差不多这个量级。然后你要算三年,2023、2024、2025。再然后你要对标竞争对手,至少三到五家同行。
9 份报表 × 5 家公司 = 45 份数据文件。
光原始数据就是 90 万 Token 起步。qwen3-max 的 256K 上下文窗口,连原始数据都装不下,更别提还要留空间给 Agent 推理和工具调用了。
这就是问题的根源。
Claude Agent SDK 的五级压缩
Claude Agent SDK 继承了 Claude Code 的五级渐进式上下文压缩策略。这五级按破坏力递增,只有在低级别无法满足需求时才会触发更高级别的压缩。

我用大白话给你解释一下每一级在干什么。
第一级,Tool Result Budget。 当某个工具返回的结果超过 5 万字符,系统会自动把完整内容转存到磁盘,上下文里只保留一个引用指针。就好像你把一份厚厚的财报塞进抽屉,桌上只放一张写着「青岛啤酒2024资产负债表在抽屉第二层」的便签纸。
第二级,Snip。 对过长的工具输出做截断,保留头部和尾部,中间用省略号替代。这级处理的是那些不算特别长但也不算短的结果,比如一整份 CSV 的内容。
第三级,Microcompact。 给历史工具结果设了一个 TTL(默认 5 分钟),超时的结果会被替换成 [old tool result content cleared] 标记。就好像你的工作台上只保留最近五分钟用过的文件,更早的都被收走了。
第四级,Context Collapse。 把整个对话轮次折叠成一个摘要块。之前所有来回的推理和工具调用记录,被压缩成一段简短的文字描述。
第五级,Autocompact。 终极手段。系统会 fork 一个子 Agent,用 LLM 对整个历史消息生成高度浓缩的摘要,然后永久替换掉原来的上下文。这是破坏力最强的操作,但在极端情况下能救命。
这五级策略层层递进,SDK 自己在后台管理,开发者完全不需要操心。
我当时看到这个设计的时候,第一反应是,这不就是我在 LangGraph 里手搓了半天的那个文件传递机制吗?只不过人家做得比我完善一万倍。
回到主线。
用 MCP 工具封装金融数据接口
Claude Agent SDK 有一个很优雅的设计,通过 @tool 装饰器和 create_sdk_mcp_server 把自定义工具注册为 MCP Server,Agent 在执行过程中可以自主调用。
工具设计有一个原则,少而精 。Agent 的工具集膨胀会挤占上下文窗口,影响推理质量。只有独立、通用的工具才定义为 MCP 工具,需要配合特定业务流程的,作为 Skills 中的脚本存在。
FinScribe 最终只保留了 6 个 MCP 工具,前 5 个是 AKShare 的金融数据接口,第 6 个是博查网络搜索。
python
from claude_agent_sdk import tool
import akshare as ak
@tool(
"get_balance_sheet", # 工具名称,Agent 通过这个名字调用
"获取沪深A股公司的资产负债表,并保存到文件中。" # 中文描述,帮助 Agent 理解工具用途
"stock_code 需带市场前缀,如 SH600600。", # 参数说明直接写在描述里
{"stock_code": str, "year": str}, # 参数 schema,声明类型让 Agent 正确生成调用参数
)
async def get_balance_sheet(args: dict) -> dict:
"""Fetch balance sheet for a given stock code and year."""
stock_code = args.get("stock_code", "SH600600")
year = args.get("year", "2025")
try:
# 标准化股票代码,确保带 SH/SZ 前缀
prefixed_code = normalize_a_share_code(stock_code)
clean_code = clean_a_share_code(stock_code)
# retry_call 包装了自动重试逻辑,默认 3 次,递增延迟
# AKShare 的网络稳定性一般,重试机制是必须的
df = retry_call(lambda: ak.stock_balance_sheet_by_yearly_em(symbol=prefixed_code))
# 从返回的多年度数据中筛选目标年度(12月31日的年报)
df = filter_annual_report(df, year, "REPORT_DATE")
if df is None or df.empty:
# 失败时不抛异常,返回文本信息让 Agent 自己决定下一步
return {"content": [{"type": "text", "text": f"No data for {prefixed_code} in {year}"}]}
# 保存为 UTF-8-SIG 编码的 CSV,确保 Excel 打开不乱码
data_dir = _get_data_dir()
filepath = os.path.join(data_dir, f"{clean_code}_{year}_资产负债表.csv")
save_dataframe(df, filepath)
# 返回文件路径而非完整数据,让 Tool Result Budget 机制管理上下文
return {"content": [{"type": "text", "text": f"Balance sheet saved to: {filepath}"}]}
except Exception as e:
# 异常处理的关键,返回错误文本而不是抛异常,不中断 Agent 流程
return {"content": [{"type": "text", "text": f"Failed: {e}"}]}
这段代码有几个设计要点我想单独说一下。
工具只返回文件路径,不返回完整数据。 这一点非常重要。如果工具直接返回完整的 DataFrame 内容,那 150 个字段的表格会立刻塞满上下文。返回路径之后,Agent 需要的时候可以用 Read 工具去读文件,Tool Result Budget 机制会自动管理大块数据的磁盘转存。
异常处理不抛异常。 @tool 函数如果抛异常,会中断整个 Agent Loop。所以所有异常都在函数内部 catch,返回一段错误文本让 Agent 自己判断该怎么处理。Agent 看到「Failed to get balance sheet」之后,可能会换个股票代码重试,也可能跳过这一步继续后面的流程。
retry 机制是标配。 AKShare 走的是东方财富的公开接口,网络波动是家常便饭。retry_call 默认重试 3 次,延迟递增(0.5s, 1.0s, 1.5s)。
python
def retry_call(func, retries: int = 3, sleep_seconds: float = 0.5):
"""Wrap a function call with automatic retry on failure."""
last_exception = None
for attempt in range(retries):
try:
return func()
except Exception as e:
last_exception = e
if attempt < retries - 1:
# 递增延迟:0.5s -> 1.0s -> 1.5s,避免雪崩式重试
time.sleep(sleep_seconds * (attempt + 1))
raise last_exception
6 个工具里有一个比较特殊,web_search,用的是博查 AI 搜索 API。这是因为 Claude Agent SDK 内置的 WebSearch 工具只对 Anthropic 原版模型可用,如果你接的是 Kimi 或者阿里通义,内置 WebSearch 就用不了。博查是目前国内联网搜索精准度比较高的方案。
7 个 Skills,把研报流程标准化
MCP 工具解决的是「怎么拿到数据」的问题,但研报生成的完整流程,从数据采集到最终成稿,中间有很多标准化的步骤。这些步骤如果全写进 system_prompt,提示词会膨胀到影响 Agent 的推理质量。
Claude Code 官方的做法是 Skills 机制。

Skills 采用渐进式加载,Agent 平时不需要看到 Skill 的完整内容,只有在需要执行某个任务时,对应的 Skill 才会被动态载入上下文。这就好比你有一柜子的操作手册,平时柜门关着不占桌面空间,需要哪本就拿哪本。
FinScribe 沉淀了 7 个标准化 Skill。
competitor_research,通过联网搜索识别竞争对手和行业基准。输入一个公司名,输出一份竞争对手列表和行业均值数据。
financial_data_collection ,采集目标公司及竞争对手的三大报表。这个 Skill 里包含了 akshare_tools.py 和 collect_financial_data.py 两个脚本,可以批量采集多家公司多年的财务数据。
financial_ratio_calculation ,根据采集到的报表数据计算关键财务比率。这个 Skill 里的脚本会读取 CSV 文件,计算毛利率、净利率、ROE、资产负债率等指标。
financial_visualization,根据财务指标生成趋势图和对比图。
valuation_modeling ,根据财务指标和行业数据进行估值测算,包括 PE、PB、DCF 等。
report_writing ,定义研报的写作规范和模板。这个 Skill 不执行脚本,而是提供 report_template.md 和 writing_style_guide.md 两个参考文档。
report_assembly,汇总前面所有 Skill 的输出,组装最终的研报。
每个 Skill 的目录结构长这样。
bash
skills/
├── financial_ratio_calculation/
│ ├── SKILL.md # 标签(name + description)+ 用途 + 脚本说明 + 工作流
│ ├── requirements.txt # Skill 独立依赖
│ └── scripts/
│ └── calculate_ratios.py # 主计算脚本,CLI 入口
SKILL.md 的标签部分(name + description)是 Agent 决定是否调用这个 Skill 的依据。写得好不好,直接决定 Agent 后续能不能准确调到这个 Skill。
yaml
---
name: financial-ratio-calculation
description: |
从中国 A 股上市公司三大报表计算关键财务比率。
当用户提到财务指标计算、毛利率、净利率、ROE、资产负债率、
流动比率、速动比率等任何与财务比率计算相关的需求时,使用此 skill。
---
加载方式很简单,在 ClaudeAgentOptions 里设置 skills="all",SDK 会自动扫描工作目录下 .claude/skills/ 文件夹里的所有 Skill。我在项目里用了一个符号链接,.claude/skills 指向 ../skills,这样 Skill 文件和项目代码放在一起,维护起来方便。
系统提示词编排,替代 LangGraph 状态图
LangGraph 的核心是显式状态图,你定义节点,定义边,定义条件转移。这种方式对复杂流程很合适,但金融研报生成其实是一个高度固定的线性工作流,不需要那么重的编排。
Claude Agent SDK 的做法是用一段系统提示词把流程写清楚,交给 Agent 自主调度。
ini
SYSTEM_PROMPT = """You are a financial research report project coordinator.
The user will provide a stock code, company name, market type, and analysis years.
Your workflow (execute ALL phases in order, in a SINGLE session):
## Phase 1: Data Collection
- Call the competitor_research skill to research competitors and industry
- Call the financial_data_collection skill to collect financial statements
## Phase 2: Metric Calculation
- Call the financial_ratio_calculation skill to calculate financial ratios
## Phase 3: Analysis & Visualization
- Call the financial_visualization skill to generate trend charts
- Call the valuation_modeling skill to generate valuation reports
## Phase 4: Report Writing
- Call the report_writing skill (implicitly follow its writing guidelines)
- Call the report_assembly skill to assemble the final research report
## Critical Execution Rules
- Execute ALL four phases in ONE session. Do NOT stop after Phase 1.
- Do NOT end your turn after spawning background tasks.
Wait for each task to finish, verify its output files exist,
THEN proceed to the next phase.
- Only end your turn AFTER the final report is assembled in Phase 4.
"""
这段提示词看着简单,但有几个细节我踩了坑才搞明白。
「Do NOT stop after Phase 1」这句是血泪教训。 最早的版本没有这句,Agent 在 Phase 1 启动了两个后台任务(竞争对手研究 + 财务数据采集),然后说了一句「请稍候,任务完成后会自动进入下一阶段」就结束了当前 turn。结果后台任务跑完了,Agent 也不见了,Phase 2 到 Phase 4 压根没执行。
加了一句「不要在启动后台任务后结束 turn,等每个任务完成并验证产物存在后再进入下一阶段」之后,Agent 就老老实实地在原地等着了。
「verify its output files exist」也很关键。 Agent 有时候会「以为」某个任务完成了,但实际上脚本执行失败了。让它在每个阶段结束后用 Read/Glob 工具检查产物文件是否存在,能大幅减少这类幻觉。
财务指标计算的公式
顺着上面的流程聊到 Phase 2,财务指标计算,这里面有一些公式我觉得值得展开讲讲。
financial_ratio_calculation 这个 Skill 的脚本会读取三大报表的 CSV,计算一系列财务比率。我把核心公式列一下,每一项都解释清楚。

其中,营业收入 (TOTAL_OPERATE_INCOME)是企业在一个会计期间内通过主营业务获得的收入总额,营业成本(OPERATE_COST)是为产生这些收入而直接发生的成本。毛利率反映了企业产品或服务的定价能力和成本控制水平。

净利润 (NETPROFIT)是扣除所有成本、费用、税金后的最终利润。净利率比毛利率更能反映企业的整体盈利能力,因为它包含了管理费用、销售费用、财务费用等间接成本的影响。

总负债 (TOTAL_LIABILITIES)是企业承担的所有债务,总资产 (TOTAL_ASSETS)是企业拥有或控制的全部经济资源。资产负债率高于 70% 通常被认为偿债风险较高,但不同行业的合理区间差异很大。

流动资产 (TOTAL_CURRENT_ASSETS)是预计在一个营业周期内变现的资产,存货 (INVENTORY)是企业持有以备出售的产成品或商品,预付账款 (PREPAYMENT)是提前支付的款项,流动负债(TOTAL_CURRENT_LIAB)是预计在一个营业周期内偿还的债务。速动比率排除了变现能力较弱的存货和预付账款,比流动比率更保守地衡量短期偿债能力。
运营能力指标

其中,平均存货 = (本年存货 + 上年存货) / 2。这个指标衡量企业从买入存货到卖出存货平均需要多少天。存货周转天数越短,说明企业的销售能力和库存管理效率越高。
你想想贵州茅台,存货周转天数能到 1300 多天,因为它存的基酒要放好几年才能勾兑出厂。而青岛啤酒大概几十天。不同行业的指标没有可比性,这也是为什么 FinScribe 要先做竞争对手研究,找到同行业的可比公司再对标。
杠杆指标

权益乘数反映的是企业的财务杠杆水平。资产负债率越高,权益乘数越大,说明企业越多地依赖债务来放大收益。这也是杜邦分析体系中的一个关键变量。
杜邦分析把 ROE(净资产收益率)分解成三个驱动因子。

其中,总资产周转率 = 营业收入 / 平均总资产,衡量资产的使用效率。这三个因子分别代表盈利能力、运营效率和财务杠杆。
杜邦分析的精妙之处在于,它让你看清 ROE 的提升到底是来自真正的经营改善(净利率或周转率提升),还是来自加杠杆(权益乘数增大)。同样的 ROE 数字背后,风险水平可能完全不同。
这些公式在 Skill 的脚本里都是确定性计算,不依赖 LLM 的推理能力。Agent 只需要调脚本,拿到结果,在研报里解读就行了。这就是「将执行逻辑代码化、将编排逻辑 Skill 化」的好处,消除了模型在简单计算上的推理不确定性。
config.py 踩坑记
聊一个比较骚的事。
Claude Agent SDK 兼容 Anthropic 协议,所以你可以接 Kimi、阿里通义、SiliconFlow 这些国产模型。切换方式是通过环境变量,ANTHROPIC_BASE_URL 指向对应的 API 端点,ANTHROPIC_MODEL 设成模型名。
我最开始用的是 os.environ.setdefault() 来设置这些变量。
lua
# 错误写法,setdefault 无法覆盖已存在的环境变量
os.environ.setdefault("ANTHROPIC_BASE_URL", "https://api.kimi.com/coding/")
os.environ.setdefault("ANTHROPIC_MODEL", "kimi-k2.6")
结果死活不生效。
排查了半天才发现,如果你用了 cc-switch 这种工具,或者 Trae IDE 预设了 ANTHROPIC_* 环境变量,那 setdefault 就不会覆盖已有的值。这个函数的名字就很迷惑,它只在变量不存在时才设置,存在则静默跳过。
正确的写法是直接赋值。
python
# config.py - 模型后端配置模块
import os
from dotenv import load_dotenv
def configure_model_backend():
"""通过 os.environ 直接赋值配置模型后端。"""
# 先加载 .env 文件,让下面的 os.getenv() 能读到配置
load_dotenv()
# 检测当前配置的是哪个后端
if os.getenv("KIMI_API_KEY"):
backend = "kimi"
elif os.getenv("ALI_API_KEY"):
backend = "aliyun"
elif os.getenv("SILICONFLOW_API_KEY"):
backend = "siliconflow"
else:
# 没有配置任何后端,使用继承的环境变量(比如 Trae IDE 管理的)
return {"backend": "environment"}
# 关键,直接赋值而非 setdefault,确保覆盖已有变量
for key in ["ANTHROPIC_BASE_URL", "ANTHROPIC_MODEL",
"ANTHROPIC_SMALL_FAST_MODEL",
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
"ANTHROPIC_DEFAULT_SONNET_MODEL",
"ANTHROPIC_DEFAULT_OPUS_MODEL"]:
value = os.getenv(key)
if value:
os.environ[key] = value # 直接赋值,覆盖一切
# 设置认证 token
# 非 Anthropic 后端用 ANTHROPIC_AUTH_TOKEN,原生 Anthropic 用 ANTHROPIC_API_KEY
api_key = os.getenv("KIMI_API_KEY") or os.getenv("ALI_API_KEY")
if api_key:
os.environ["ANTHROPIC_AUTH_TOKEN"] = api_key
return {"backend": backend}
# 模块导入时自动执行配置,import config 就会触发副作用
_ACTIVE_BACKEND = configure_model_backend()
这个 config.py 的设计是,模块导入时就自动执行 configure_model_backend(),所以 agent.py 里只要 from config import get_backend_info,配置就自动生效了。不需要显式调用任何初始化函数。
当然如果你不配 .env 文件,也不影响运行,config.py 会检测到没有后端配置,直接使用继承的环境变量。比如在 Trae IDE 里,模型配置是 IDE 管理的,不需要 .env。
跑起来看看效果
服务跑起来之后,我在前端输入了「青岛啤酒 SH600600,分析年份 2024、2025」,点了一下开始。
WebSocket 实时推送的第一条消息是 Agent 的初始化信息。
yaml
[System] Agent initialized | Model: doubao-seed-2.0-pro | Tools: 34 loaded
34 个工具加载完毕,Agent 开始按系统提示词的四阶段流程执行。
Phase 1,Agent 先调了 competitor_research Skill,通过博查搜索识别出了青岛啤酒的主要竞争对手,燕京啤酒(000729)、珠江啤酒(002461)这些。然后调 financial_data_collection Skill,通过 MCP 工具批量采集了青岛啤酒和竞争对手的三年财务报表。
我看着 data 目录下的 CSV 文件一个个冒出来。
erlang
600600_2024_资产负债表.csv
600600_2024_利润表.csv
600600_2024_现金流量表.csv
600600_2024_财务指标.csv
000729_2024_资产负债表.csv
000729_2024_利润表.csv
...
最后数了一下,56 个 CSV 文件。
Phase 2,Agent 调了 financial_ratio_calculation 的脚本,读取这些 CSV,算出了毛利率、净利率、ROE、资产负债率等指标。
Phase 3,Agent 生成了趋势图和对比图,还做了估值测算。
Phase 4,Agent 按照研报写作规范,组装了一份完整的 Markdown 研报,保存到 data/final_output/ 目录。
整个过程,Agent 执行了 300 多条消息,中间有大量的工具调用、文件读写、脚本执行。得益于 SDK 的五级上下文压缩,从头到尾没有出现 Token 溢出错误。
如果还是用之前的 LangGraph 方案,光 56 个 CSV 的原始数据就已经超出上下文窗口了。
一些反思
坦率的讲,这个项目跑通之后我最大的感受不是「Claude Agent SDK 多么牛逼」,而是「不要自研基础设施」。
之前在 LangGraph 里手搓文件传递机制的时候,我花了很多时间在上下文管理这个事情上。但上下文管理是 SDK 的职责,不是业务代码的职责。你自研的再好,也不可能比官方团队做得更完善,因为人家有完整的工程团队在持续迭代。
Claude Agent SDK 的五级压缩策略,从 Tool Result Budget 到 Autocompact,层层递进。你不需要知道每一级的实现细节,你只需要信任它会在合适的时候触发合适的策略。
这种感觉怎么说呢,就好像你以前每天手动管理服务器的内存分配,现在换了个自动垃圾回收的语言。你知道底层在干什么,但你不需要操心了。
另外 Skills 机制的设计也很聪明。把「怎么做一件事」的方法论从上下文中剥离出来,按需加载。这比把所有指令都塞进 system_prompt 要高效得多。7 个 Skill 挤在一个 Agent 里确实有些臃肿,后续可能需要考虑把部分 Skill 拆分为独立 SubAgent 执行。
我自己也还在摸索,有些想法可能还不成熟。但至少方向是对的,用 SDK 的内置能力替代自研基础设施,把精力集中在业务逻辑上。