金融Agent百技图:用Skill架构让智能体从能聊到能干可审计

发布时间:2026/10/31 15:51:39
金融Agent百技图:用Skill架构让智能体从能聊到能干可审计 上周和几个做金融科技的朋友吃饭聊到一个共同的困惑Agent的Demo做得一个比一个漂亮一进生产环境就原形毕露。问研报能说个大概让它精确算一笔持仓的久期就开始胡编能聊产品条款一碰到这笔钱到底能不能买这种需要带适当性判断的问题就只会打太极。大家心里其实都清楚问题不在模型能力而在于中间缺了一层东西——把金融业务里的原子能力拆成Agent能稳定调用、能被审计、能被复用的Skill。这也是我最近花了不少时间研究某云厂商开源的《金融Agent百技图》的原因一百多个实战技能加上一套通用架构本质上是在回答一个问题金融Agent怎么从能聊走到能干、敢干、干得有据可查。这篇东西适合谁看如果你正在做金融行业的智能客服、投研助手、财富管理顾问助理、信贷审批辅助或者你只是单纯想把Agent从玩具做成工程件那这套思路都值得抠一遍。我会按为什么这么设计—架构怎么拆—单个技能怎么落地—风控怎么兜底—效果怎么衡量—坑在哪的顺序讲代码和配置都能直接抄参数也会给计算依据。1. 先搞清楚百技图到底在解决什么问题1.1 从Demo惊艳到生产可用中间隔着一条鸿沟我自己踩过最典型的一次坑是给一个投研场景做问答助手。Demo阶段拿几个标准问题喂进去答得眉飞色舞团队信心爆棚。上线第二天就被业务方打回来问某基金近三年最大回撤是多少模型给了一个看着很合理的数字但实际上是把两个不同口径的指标混在一起算的。这种错误在闲聊场景无所谓在金融场景是事故。金融业务的三个特性决定了通用Agent很难直接可用。第一是数值必须精确收益率、久期、波动率、夏普比率任何一个口径错了都是误导第二是结论必须可追溯凭什么说这个客户适合R3产品依据是哪条规则、哪份数据第三是边界必须清晰哪些话能说、哪些话不能说、哪些必须转人工这些不是靠提示词请注意合规就能解决的。《金融Agent百技图》的思路很直接把上述这些东西从提示词里的一厢情愿变成代码里的硬约束。每个技能都是一个有明确输入输出契约、有执行体、有权限边界的独立单元模型只负责选和填参数真正的计算和取数交给确定性代码。这个分工一旦确立幻觉的生存空间就被压缩了大半。1.2 三层抽象能力层、契约层、执行层我把这套东西拆开看核心是三层抽象理解这三层后面所有细节都能对上号。能力层回答Agent会什么。一百多个技能不是随便堆的它们按业务域被组织成树状结构客户与账户、产品与条款、行情与数据、投研与分析、交易与流程、合规与风控、运营与文档。每个叶子节点就是一个可被调用的最小能力。这种组织方式的好处是技能的召回可以先按域粗筛再做细粒度匹配比在几百个技能里做全量向量检索要稳得多也快得多。契约层回答怎么调用。每个技能都有一份结构化描述包含技能名、功能说明、适用场景、输入参数 Schema、输出结构、数据分级、调用前置条件。这份描述不是给人看的文档而是同时给模型看的选择依据和给运行时用的校验规则。这一点非常关键后面我会专门讲为什么Schema比Prompt重要一百倍。执行层回答谁来干。执行体可以是纯函数比如算久期、可以是内部API封装比如查客户持仓、可以是文档解析流水线比如从PDF招募说明书里抽费率表、也可以是一段编排工作流比如开户预审要串起身份核验、风险测评、黑名单比对三步。执行体与模型完全解耦可以单独测试、单独发版、单独限流。1.3 一百多个技能的价值在于覆盖业务长尾有人会问真的需要一百多个吗做个万能Agent不行吗我的答案是在金融场景万能往往等于万不能。原因是金融业务的请求分布极度长尾。头部是高频问题查余额、查净值、问费率占比可能到七成但真正消耗人工坐席时间、也真正影响客户体验的是剩下三成的长尾问题——我这个情况能不能做质押、分红方式改成红利再投要多久生效、跨境汇款的中间行扣费怎么算。这些问题的共同点是出现频率低、但一旦问到就必须给准话靠模型自由发挥必然出错。把长尾问题逐个固化成技能短期看是笨功夫长期看是资产。技能一旦成型可以被任意Agent复用可以被回归测试覆盖可以在出问题时精准定位到某一个模块。这比维护一个巨大无比、谁也不敢动的超级提示词要健康得多。我在实际项目里的体会是技能库的规模增长曲线基本等于这个团队对业务理解的深度曲线。2. 通用架构拆解一个Skill从注册到执行的完整链路2.1 整体分层接入、编排、技能、数据、治理把这套架构摊平我习惯分成五层来看每层的职责边界要划清楚否则后面一定乱。接入层负责会话管理、渠道适配App、小程序、企微、电话IVR、身份识别与鉴权。这一层的关键是把谁在问确定下来因为金融场景里同一个问题不同客户等级、不同风险承受能力答案可能完全不同。身份没锚定后面全是空中楼阁。编排层是大脑负责意图理解、技能召回、参数抽取与补全、多技能编排、澄清追问、结果组织。这一层用大模型但只让它做它擅长的事——理解自然语言、做选择和排序不做事实性判断。技能层是手脚一百多个技能在这里注册、发现、执行。每个技能独立部署或独立打包有版本号有健康检查。数据层提供统一的数据访问入口包括行情、产品库、客户画像、文档库、知识库。这层最重要的是做口径统一同一个年化收益率在不同系统里有五种算法如果不在这层收敛技能层怎么写都是错的。治理层横向切过去管权限、限流、审计、评测、灰度。这层最容易被忽略也最容易在上线后变成救火现场。2.2 技能描述规范为什么Schema比Prompt重要这是我特别想强调的一点。很多人做Agent习惯在系统提示词里写一大段你可以使用以下工具……工具描述全靠自然语言。这在几个工具时能用到几十上百个就崩了。崩的原因有三个。第一描述模糊导致误选比如两个技能一个叫查询产品信息、一个叫查询产品详情模型根本分不清随机选一个第二参数缺失导致瞎填模型不知道某个参数是必填还是可选就自己编一个看起来合理的值第三输出不可校验模型拿到返回值后不知道结构只能再猜一遍。结构化描述解决的正是这三点。我给技能写元数据时固定包含这几块字段作用写法要点name唯一标识动词加名词全小写下划线避免近义撞名display_name展示名面向运营可读中文即可description功能说明一句话讲清做什么不要写可以用于这类模糊词when_to_use适用场景列 2-3 个典型问法帮助模型判断when_not_to_use不适用场景明确排除项比正向描述更能降低误选inputs参数 Schema每个参数标注类型、是否必填、取值范围、单位outputs输出结构字段固定避免自由文本data_level数据分级公开/内部/敏感/极敏感用于权限和脱敏side_effect是否有副作用只读、可重试写、不可重试写其中when_not_to_use和side_effect这两个字段我在别的方案里很少见到但实战价值极高。前者能把误选率压下去一大截后者直接决定了运行时要不要加二次确认和幂等键。提示技能描述一定要由写代码的人来定不能让运营或者提示词工程师代笔。描述里任何一个含糊的词最后都会变成线上的一次误调用。2.3 编排与路由先召回再排序别一步到位技能数量上百之后直接让模型从全量列表里选效果会明显下降。我们实测过技能数超过三十个以后Top1选择准确率会掉十个百分点以上而且随着列表变长还在继续掉。比较稳的做法是两级路由。第一级是域召回用规则加轻量向量检索把候选从一百多个收敛到五到八个。域召回的信号可以很简单关键词命中净值赎回费率、实体识别提到了具体的产品代码或客户号、会话上下文上一轮已经在聊持仓这轮大概率还在持仓域。第二级是模型排序把这五到八个技能的完整描述喂给模型让它选一个并填充参数。这样做的另一个好处是可解释。出了问题可以看日志是域召回没把对的技能捞出来还是捞出来了但模型选错了还是选对了但参数填错了。三个环节的修复手段完全不同混在一起就永远修不好。参数抽取我一般分三步走。先从用户原话里抽显式参数抽不到的从会话历史里继承比如上一轮说过的产品代码还缺的就看有没有默认值或者能不能查画像补全最后实在缺关键参数才发起澄清追问。澄清追问要克制一次只问一个问的时候把已识别的信息复述一遍用户体验会好很多。2.4 记忆与上下文三种记忆别混着用金融Agent的记忆我分成三类混用会出问题。会话记忆是当前这轮对话的上下文生命周期几十分钟用来解决指代消解和参数继承。这层用滑动窗口加摘要就够了不需要向量库。客户长期画像是跨会话的稳定信息包括风险等级、持仓偏好、历史咨询主题。这层必须从业务系统实时读取不能缓存太久因为风险等级是会变的拿旧画像做适当性判断是硬伤。业务事实缓存是数据查询结果的短期缓存比如产品费率表、净值序列。这层要带时间戳并且明确标注数据截止时间。金融数据最忌讳看起来是新的其实是旧的一个三天前的净值被当成实时净值报出去比报错还糟糕。我的做法是所有数据类技能的输出结构里强制包含as_of字段编排层在组织回答时必须把这个时间说出来。3. 关键实操把一个金融技能真正跑通3.1 目录结构与最小可跑环境技能库的目录我建议按域分每个技能一个文件夹内部结构固定这样新人接手能立刻找到东西。skills/ ├── market/ │ ├── bond_duration_calc/ │ │ ├── skill.yaml # 元数据与契约 │ │ ├── handler.py # 执行体 │ │ ├── schema.py # 参数与输出校验 │ │ ├── tests/ │ │ │ ├── cases.yaml # 用例集 │ │ │ └── test_handler.py │ │ └── README.md │ └── nav_query/ ├── compliance/ ├── research/ └── _shared/ # 公共工具日期、精度、脱敏依赖上其实很轻Python 3.10 以上pydantic 做参数校验pyyaml 读配置pytest 跑测试再加一个数值计算库。不需要引入任何重型框架技能本身就该是薄薄一层越薄越稳。3.2 技能元数据定义拿债券久期计算举例这是个典型的纯计算技能没有副作用但精度要求极高。name: bond_duration_calc display_name: 债券久期与凸性计算 version: 1.3.0 description: 根据债券现金流、到期收益率与付息频率计算麦考利久期、修正久期与凸性 when_to_use: - 用户询问某只债券的久期、利率敏感度 - 需要评估利率变动对债券价格的影响幅度 when_not_to_use: - 用户只是询问债券基本信息应使用 bond_profile_query - 用户询问的是组合层面的加权久期应使用 portfolio_duration inputs: face_value: type: number required: true range: [1, 100000000] unit: 元 desc: 面值通常为100 coupon_rate: type: number required: true range: [0, 0.3] unit: 小数年化 desc: 票面利率注意是小数不是百分数 ytm: type: number required: true range: [-0.1, 0.5] unit: 小数年化 desc: 到期收益率 periods: type: integer required: true range: [1, 60] desc: 剩余付息期数 freq: type: integer required: false default: 1 enum: [1, 2, 4] desc: 年付息频率 outputs: macaulay_duration: number modified_duration: number convexity: number as_of: string data_level: internal side_effect: readonly这里有个细节值得说coupon_rate我特意标了小数不是百分数并且给了取值范围[0, 0.3]。因为模型极容易把3.5%填成3.5而不是0.035。加了范围和单位说明之后如果它填了3.5校验会直接拦下来触发重试而不是算出一个荒唐的结果。这种用 Schema 兜住模型的常见手滑是整套设计里性价比最高的部分。3.3 执行体与参数校验执行体要写得像普通业务代码不要在里面掺任何模型调用。from pydantic import BaseModel, Field, field_validator from datetime import datetime, timezone class DurationInput(BaseModel): face_value: float Field(gt0, le1e8) coupon_rate: float Field(ge0, le0.3) ytm: float Field(ge-0.1, le0.5) periods: int Field(ge1, le60) freq: int Field(default1) field_validator(coupon_rate) classmethod def percent_guard(cls, v): # 拦截把百分数当小数传的典型错误 if v 0.3: raise ValueError(coupon_rate 应为小数年化如 3.5% 请传 0.035) return v def bond_duration_calc(payload: dict) - dict: p DurationInput(**payload) c p.coupon_rate / p.freq * p.face_value r p.ytm / p.freq n p.periods # 现金流贴现 pv_list, t_list, cf_list [], [], [] for t in range(1, n 1): cf c (p.face_value if t n else 0.0) pv cf / ((1 r) ** t) pv_list.append(pv) t_list.append(t) cf_list.append(cf) price sum(pv_list) if price 0: raise ValueError(现值为非正请检查输入参数) mac_d sum(t * pv for t, pv in zip(t_list, pv_list)) / price / p.freq mod_d mac_d / (1 r) # 凸性 convex sum( pv * t * (t 1) / ((1 r) ** 2) for t, pv in zip(t_list, pv_list) ) / price / (p.freq ** 2) return { macaulay_duration: round(mac_d, 6), modified_duration: round(mod_d, 6), convexity: round(convex, 6), as_of: datetime.now(timezone.utc).isoformat(), }注意几个地方小数位统一保留六位避免不同技能精度不一致as_of强制打时间戳异常直接抛不要吞掉返回一个大概的值。最后这条我吃过亏早期为了用户体验在计算失败时返回一个估算值结果被业务方当成真值用在了报告里。3.4 编排入口与二次确认编排层的伪代码大概长这样核心是先召回、后排序、再校验、最后执行。def run(user_query, session): domain recall_domain(user_query, session) # 规则向量收敛到域 candidates list_skills(domain, limit8) # 取域内技能描述 plan llm_select_skill(user_query, candidates) # 模型选择填参 skill resolve(plan.skill_name, plan.version) # 定位到具体版本 try: args skill.validate(plan.args) except ValidationError as e: return ask_clarify(e, skill) # 参数不合法则追问 if skill.side_effect write_irreversible: if not session.confirmed(plan.fingerprint): return request_confirm(plan) # 不可逆操作强制二次确认 with skill.sandbox(session.user): result skill.execute(args) return compose_answer(result, skill.outputs_schema)session.confirmed里的指纹是对技能名 关键参数做的哈希用户确认过一次就不再重复问但如果参数变了指纹就变会重新问。这个机制看着简单实际能把用户改了个金额但系统以为还是原来那笔的误操作挡下来。3.5 权限沙箱与副作用隔离金融场景里顺手一个越权查询就可能变成数据泄露事故。我的做法是每个技能在元数据里声明数据分级运行时按用户身份裁剪。具体实现上技能执行时不直接拿数据库连接而是通过一个受控的数据访问代理代理会做三件事按用户身份注入数据范围过滤比如只能查本人名下账户、按分级自动脱敏手机号中间四位打码、卡号只留后四位、记录访问审计。技能代码本身完全感知不到这些它拿到的就是已经裁剪过的数据。这层隔离一旦建立新技能上线就不需要逐个做安全评审只需要审元数据里的分级声明对不对。注意只读技能和写技能一定要分开部署。写操作要走独立的确认通道、独立的重试策略、独立的限流阈值混在一起一个批量查询就能把交易接口的配额吃干净。3.6 联调与回归测试每个技能必须配一份用例集格式我用 YAML包含正常用例、边界用例、负例三类。cases: - name: 标准年付债券 input: {face_value: 100, coupon_rate: 0.035, ytm: 0.04, periods: 5, freq: 1} expect: modified_duration: {approx: 4.5, tol: 0.1} - name: 零息债券 input: {face_value: 100, coupon_rate: 0.0, ytm: 0.03, periods: 3, freq: 1} expect: modified_duration: {approx: 2.91, tol: 0.05} - name: 百分数误传 input: {face_value: 100, coupon_rate: 3.5, ytm: 0.04, periods: 5, freq: 1} expect: {error: coupon_rate 应为小数年化}负例比正例更重要。我见过太多技能库正例测得漂漂亮亮一遇到脏数据就崩因为没人写如果传错了会怎样。负例用例的写法就是把你踩过的坑逐条固化成断言。4. 金融场景的硬约束风控、合规与可追溯4.1 输出前的三道闸门模型生成的自然语言回答不能直接吐给用户中间要有闸门。我一般设三道。第一道是事实闸门检查回答里出现的所有数字是否都能在执行结果里找到对应字段。做法是把回答里的数字抽出来与本次技能返回的结构化结果做比对找不到来源的数字一律拦下重写。这一道能拦掉绝大部分数值幻觉。第二道是合规闸门基于规则做关键词与句式检查。禁用词、绝对化表述稳赚保本必涨、承诺性表述保证收益一定通过、未授权产品推荐这些都是规则能覆盖的。规则库要能热更新监管口径变化比代码迭代快得多。第三道是适当性闸门检查推荐内容与客户风险等级是否匹配。这一步需要读取客户画像是个业务规则问题不是语言问题必须用代码判断。三道闸门都是后置的、确定性的不依赖模型自觉。我一直觉得把合规寄托在提示词里的请注意合规上是一种自我安慰。4.2 数据分级与脱敏的落地细节分级我分四级公开产品宣传信息、内部内部研究材料、敏感客户持仓与交易记录、极敏感身份信息、银行账号。技能声明自己能处理哪一级运行时校验用户权限是否覆盖。脱敏有个容易被忽略的点同一份数据在不同技能里要脱敏成同样的样子。比如客户手机号查询技能脱敏成138****5678通知技能如果脱敏成1385***678用户看到的就不一致会被认为系统有 bug。所以脱敏规则必须统一放在_shared里不能各写各的。还有一个坑是日志泄露。技能执行时的入参出参经常被完整打进日志敏感字段就这么流出去了。我的做法是日志中间件按字段名白名单做过滤只有白名单里的字段才明文落盘其余一律哈希或打码。这件事一定要在上线前做上线后补就晚了。4.3 审计日志与可解释性金融场景被问得最多的一句话是为什么给出这个结论。所以每次技能调用都要留下可追溯的记录谁在什么时候、因为什么问题、调用了哪个技能的哪个版本、传了什么参数、返回了什么、经过了哪几道闸门、闸门有没有拦截。这条链路完整了出问题才能复盘。我遇到过客户投诉系统说我能买但我实际买不了翻日志发现是风险测评结果在调用后十分钟更新了系统用的是旧等级。如果没有版本号和时间戳这种问题根本查不出来只能各打五十大板。5. 评测体系怎么证明技能真的有用5.1 离线评测集怎么建技能库最容易犯的错是只测能不能跑通不测选得对不对。我的做法是建一套任务级评测集每个技能配 20 到 50 条覆盖标准问法、口语化问法、含错别字的问法、跨域混合问法、以及明确应该拒答的负例。评测集不能自己拍脑袋写要从真实会话日志里采样然后人工标注正确答案。我一般按 7:2:1 分配七成来自真实高频问题两成是人工构造的边界情况一成是刻意设计的对抗样本比如故意混淆两个相似技能的问法。这一成对抗样本最能暴露路由设计的问题。5.2 关键指标与灰度策略指标我盯这几个每个都有明确的计算方式指标定义参考目标技能选择准确率 Top1选中正确技能的比例大于 0.88技能选择准确率 Top3正确技能在候选前三的比例大于 0.97参数完整率必填参数一次填全的比例大于 0.90参数正确率填对的参数占已填参数的比例大于 0.95执行成功率技能执行无异常的比例大于 0.99结果采纳率用户未追问或未转人工的比例大于 0.85P95 时延端到端 95 分位耗时小于 3 秒单次成本单次会话的模型与算力成本按业务定Top3 这个指标很重要因为它反映的是域召回的质量。Top1 上不去但 Top3 很高说明召回没问题、排序有问题优化方向很明确如果 Top3 都很低那就是域划分或者召回策略出了根本性问题得回头重构。灰度我按技能维度做新技能先对 5% 流量开放观察一周的选择准确率和执行成功率没问题再逐步放量。这里有个细节灰度要按用户维度而不是请求维度分桶否则同一个用户一会儿用新技能一会儿用旧的体验会非常割裂。6. 踩坑实录与排查速查表6.1 几个印象深刻的故障故障一相似技能互相抢单。查询产品费率和查询产品详情两个技能描述里都有产品和查询模型经常选错。修复办法是把它们合并成一个技能用参数区分返回粒度。经验是当两个技能的描述用自然语言没法一句话说清区别时就该合并。故障二参数继承串味。用户先问了A产品的费率又问B产品的净值结果净值查询继承了上一轮的A产品代码。修复办法是参数继承加实体时效性——继承的参数只在后续两轮内有效且如果本轮语句里出现了新的实体必须覆盖继承值。故障三缓存击穿导致数据陈旧。行情类技能做了五分钟缓存但缓存 key 里没带用户身份导致不同用户拿到同一份数据。这个在标准化行情上无所谓但在带客户等级的差异化报价上就是事故。缓存 key 必须包含所有影响结果的维度。故障四超时重试引发重复提交。一个预约类技能超时后自动重试结果用户被预约了两次。修复是引入幂等键键值由用户ID加业务要素哈希生成服务端做去重。6.2 常见问题速查表现象可能原因定位方法处理模型频繁选错技能描述含糊或存在近义技能看误选的两个技能描述对比补 when_not_to_use 或合并技能参数总是缺Schema 未标必填或未给默认值统计各技能参数完整率补默认值或增加画像补全数值明显不对单位或量纲错误抽样比对执行结果与人工核算加范围校验与单位说明回答里出现无源数字模型自行补全数字溯源比对启用事实闸门强制重写时延突然升高域召回范围扩大或数据源变慢分阶段埋点收窄召回或加缓存同类问题答法不一致脱敏或话术未统一对比不同技能输出规则下沉到公共模块用户反复追问同一件事澄清策略过于激进看追问轮次分布减少追问改为一次问全心得排查这类问题最重要的是把链路切段埋点——召回耗时、排序耗时、执行耗时、生成耗时分开统计。混在一起的端到端时延除了告诉你慢什么信息都没有。7. 二次开发的取舍先接哪些、怎么扩展7.1 从一百多个技能里挑哪些先接现成的技能库摆在那最容易犯的错是贪多一股脑全接进来结果哪个都没调好。我的建议是按三个维度打分排序业务频次这个技能对应的问题一天被问多少次、人工替代成本原先需要坐席花多久处理、错误代价答错了会造成多大影响。优先接的是高频 高替代成本 低错误代价的组合比如产品信息查询、费率查询、净值查询、常见业务流程指引。这类技能上线快、见效快、风险低能快速建立业务方信心。低频 高错误代价的比如适当性判断、交易确认一定要放在后面等评测体系和闸门都跑稳了再上。7.2 自建技能的复用套路自建技能时我摸索出一个挺省事的套路先把同一业务域里重复出现的逻辑抽到_shared包括日期处理、精度处理、脱敏、口径换算、错误码。这些东西看起来琐碎但它们是技能之间一致性的来源。举个例子日期处理。金融里的近一年到底是从今天往前推365天还是上一个自然年的区间不同业务方理解不一样。如果不统一十个技能能给你算出五种结果。我在_shared里定义了resolve_period(label, anchor)这样一个函数把所有期间表达式的解释权收归一处技能只调用不实现。这种收敛做过一次之后后面新增技能的成本会明显下降。另外技能版本管理要提前定规矩。我的做法是语义化版本参数新增算小版本参数删除或语义变更算大版本大版本必须保留旧版至少一个季度编排层按版本路由。金融系统的迁移周期长不给缓冲期一定会出乱子。8. 我在实际落地中的几点体会这套东西我从头到尾跟过一遍有几句话想说给准备上手的人。第一别指望模型解决确定性问题。凡是能写成公式、能查表、能调接口的一律下沉成技能。模型的位置在理解和表达两端中间的事实判断它不该碰。第二技能描述的投入产出比高得离谱。花两个小时把when_to_use和when_not_to_use写清楚可能省掉后面两周的误调用排查。这件事没有技术含量但极其值钱。第三闸门要在上线前建不能等出事再补。数值溯源、合规检查、适当性校验这三道闸门一旦缺失前面所有技能做得再漂亮业务方也不敢让你上生产。第四评测集要当成资产养。每次线上出问题就把那个 case 补进评测集慢慢你会发现这套用例集比任何文档都更能反映系统的真实水平。最后分享一个我自己在用的小技巧给每个技能加一个debug_sample字段放一条完整的调用示例包括输入和期望输出。联调时新人不用看文档直接跑这个示例就能确认环境通不通、链路对不对。这个字段只有十几行 YAML但省下来的沟通成本远超写它的那点时间。