用 Claude Agent SDK 干掉 LangGraph 之后,我的金融研报 Agent 终于不崩了

前段时间我在搞一个金融研报自动生成的项目,用 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.pycollect_financial_data.py 两个脚本,可以批量采集多家公司多年的财务数据

financial_ratio_calculation ,根据采集到的报表数据计算关键财务比率。这个 Skill 里的脚本会读取 CSV 文件,计算毛利率、净利率、ROE、资产负债率等指标。

financial_visualization,根据财务指标生成趋势图和对比图。

valuation_modeling ,根据财务指标和行业数据进行估值测算,包括 PE、PB、DCF 等。

report_writing ,定义研报的写作规范和模板。这个 Skill 不执行脚本,而是提供 report_template.mdwriting_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 的内置能力替代自研基础设施,把精力集中在业务逻辑上。

相关推荐
顿哥GPT2 小时前
2026年8月更新:ChatGPT与Codex深度实践——从Token消耗到AI编程效率优化,开发者如何管理自己的AI用量(GPT-5.6 最新分享)
人工智能·chatgpt·ai编程
xfan_me2 小时前
全国今日油价 API-油价查询-油价查询接口
大数据·人工智能·信息可视化
only-qi2 小时前
美的AI Agent面试题的解析与思考
人工智能·ai·llm·agent·react
谢尔登2 小时前
分享一些我常用的Skill
java·人工智能·python·actionscript
白拾2 小时前
【CVPR 2026】CoF:Chain-of-Frames,让视频大模型按帧推理|从多模态视频推理范式视角
人工智能·多模态大模型·视频理解·cvpr 2026·cof 论文分享·链式推理·帧感知推理
星核0penstarry2 小时前
超越 VLA:NVIDIA 解读|世界动作模型,会是具身智能的未来吗?
人工智能·机器人
科里 Coralyx2 小时前
评测凭什么成为模型护城河:Agent评测的跨厂机制分析
大数据·人工智能·ai
武子康2 小时前
VLA 落地先签动作合同:从视觉语言输入到可执行控制指令
人工智能·llm·agent
知识燃料3 小时前
企业级生成式 AI 云平台推荐,哪些平台更适合从 Agent 原型走向生产?
大数据·人工智能