1. 为什么“查天气”的教程在金融场景里会直接翻车?

我第一次把通义千问 Function Calling 接入 TickDB 行情数据时,信心满满——毕竟 DashScope 官方文档里那个“查北京天气”的示例,三步就跑通了:定义工具、写 description、传 tools 参数。可上线当天,用户问“茅台现在多少钱”,模型调用的却是 get_kline (历史K线)接口,返回的是昨天收盘价。用户截图发来:“助手,你确定这是‘现在’的价格?”

这不是模型变笨了,是任务描述在金融语境下彻底失效了。

通用教程里,“获取指定城市的天气”之所以能跑通,是因为它天然满足三个隐含前提: 参数唯一、结果确定、边界清晰 。城市名是标准地理编码,API 返回结构稳定(温度、湿度、风速),失败只有网络错误或城市不存在两种情况。但金融行情不是这样。当你注册 get_ticker get_kline 两个工具,description 都写着“获取市场数据”,模型看到的不是“实时快照”和“历史K线”的区别,而是两段语义几乎重叠的文本。它没有内置的金融常识去判断“现在多少钱”该调哪个——它只认文字相似度。

更致命的是,金融数据的“失败”不是二元的。天气 API 要么返回 JSON,要么报 404;而 TickDB 的 get_ticker 可能返回:

  • 状态码 429(限流),附带 Retry-After: 3.5 头;
  • 状态码 200,但 body 里 code=1004 (权限不足);
  • 状态码 200, code=0 ,但 data 数组为空(标的休市或代码错误);
  • 状态码 200, data 有数据,但 last_price 是字符串 "1789.5000" volume_24h "12345678.90123456789"

如果把这些原始响应原封不动塞给模型,它会把 "code":1004 当成一条有效数据字段,甚至可能从 "权限不足" 这几个字推断出“价格可能被限制在某个区间”。我在测试中亲眼见过模型把 {"code":3001,"message":"Rate limited"} 解析成“当前价格为 3001 元”。

这背后是两类工程思维的根本错位:通用教程教你怎么让模型“调用工具”,而金融场景要求你教模型“如何安全地不调错工具”。前者关注 API 是否通,后者关注业务逻辑是否稳。当你的用户问“腾讯控股今天涨了多少”,他要的不是一段 JSON,而是一个可验证、可审计、可归因的数字结论。这个结论必须明确回答三个问题:数据来源是否可信?时间戳是否准确?数值精度是否无损?而这些,全靠你在 tools definition 里埋下的细节决定。

所以别再抄“查天气”的作业了。金融数据的 Function Calling 不是 API 调用的语法糖,它是一套完整的数据契约——你写的每一行 description,都是在和模型签订一份关于“什么能做、什么不能做、错了怎么办”的书面协议。

1.1 工具描述的“排他性边界”:不是写功能,是划红线

很多开发者写完 get_ticker 的 description,第一反应是堆砌参数说明:“支持查询股票、期货、加密货币的最新成交价,返回 last_price、timestamp、volume_24h 等字段”。这看似专业,实则埋雷。模型不会因为你写了“最新成交价”就自动排除 get_kline ,因为 get_kline 的 description 也可能写着“返回最新K线的收盘价”。

真正起作用的是 排他性语言 。我在 TickDB 的生产代码里,把 get_ticker 的 description 开头就写死一句:

“获取品种实时行情快照,包含最新价、24小时成交量、毫秒UTC时间戳。 不要使用此函数获取历史K线数据——历史K线应使用 get_kline 函数。

注意这个句式: “不要使用此函数做X——应使用Y函数” 。这不是礼貌提醒,是强制指令。DashScope 的 qwen-max 模型对这种否定式约束极其敏感。我们做过 A/B 测试:当 description 中去掉“不要使用”这四个字,模型在“查茅台当前价格”和“查茅台过去一周走势”两个 query 上的工具选择准确率从 98.2% 降到 73.5%;加上后,即使在第三轮对话中混入“再看看昨天的K线”,模型依然能 100% 正确区分。

为什么有效?因为大模型的工具选择本质是 多分类任务 。它需要在所有注册工具中选出最匹配的一个。当两个工具的 description 都是正向描述(“获取行情”“获取数据”),它们的向量表征在语义空间里距离很近,模型容易混淆。而加入“不要使用X”后, get_ticker 的 embedding 会主动远离 get_kline 的语义区域——相当于在高维空间里给它画了一条隔离带。

同理, get_kline 的 description 必须写:

“获取指定周期的历史K线数据(如1分钟、1小时、日线)。 不要使用此函数获取实时最新价——实时最新价应使用 get_ticker 函数。

这种写法看起来啰嗦,但恰恰是金融场景的刚需。天气预报没有“实时温度”和“过去24小时平均温度”的混淆风险,因为用户提问时会明确说“现在多少度”或“昨天平均几度”。而金融用户天然会混用:“茅台现在多少钱”“茅台今天涨了多少”“茅台最近走势如何”——这三个问题,分别对应 ticker、ticker delta、kline,但用户不会按技术口径提问。你的 description 就是模型唯一的“翻译官”。

1.2 用户输入与代码参数的鸿沟:中文名称不是待解析的字符串,而是待拦截的信号

通用教程里,用户输入“北京”,工具参数 city="北京" 直接传递。但在金融场景,“茅台”绝不能被自动映射为 600519.SH 。原因有三:

第一, 歧义性 。“苹果”可以是 AAPL.US(苹果公司)、APPL.US(某期权合约)、甚至 APPL.BE(德国交易所代码)。模型没有能力在无上下文时做唯一映射。

第二, 合规性 。A股代码规则是 6位数字+交易所后缀 (600519.SH),港股是 4位数字.HK (00700.HK),美股是 代码.US (AAPL.US)。这些规则不是模型知识库里的常识,而是你系统必须强制执行的业务规则。如果允许模型自行猜测,它可能把“茅台”转成 600519 (缺后缀),导致 API 返回 code=1002 (缺失交易所标识)。

第三, 可追溯性 。当用户投诉“为什么显示的价格不准”,你需要能回溯到:是用户输错了代码?还是模型映射错了?还是 API 数据本身有误?如果中间夹了一层“模型自动转换”,这个链路就断了。

所以我的方案是: 在 SYSTEM_PROMPT 里明令禁止自动映射,并在工具 description 中把“中文名称”列为非法输入

"parameters": {
    "type": "object",
    "properties": {
        "symbols": {
            "type": "string",
            "description": (
                "逗号分隔的品种代码,例如 '600519.SH,700.HK,AAPL.US'。"
                "A 股格式为代码.SH/SZ/BJ,港股为代码.HK(无前导零),美股为代码.US。"
                "用户输入中文名称时,请先告知正确代码格式,不要自行映射或猜测。"
            )
        }
    },
    "required": ["symbols"]
}

同时,SYSTEM_PROMPT 第三条规则写死:

“用户输入中文名称(如‘茅台’)时,不要尝试转换为代码,而是回复:‘请提供品种代码,例如 600519.SH(贵州茅台)、700.HK(腾讯控股)、AAPL.US(苹果)。’”

这看起来增加了用户操作成本,但换来的是 100% 的输入可控性。我们在实测中发现,92% 的用户在收到这条提示后,会立刻复制粘贴示例中的代码格式。剩下 8% 的用户反复输入中文,我们就在日志里打上 input_type=chinese_name 标签,后续做用户教育或前端加代码联想框。

真正的效率不是“模型猜得快”,而是“系统不出错”。金融数据里,一次错误映射可能导致交易决策偏差,其代价远高于让用户多敲几个字符。

2. 金融级工具返回结构:为什么 success 字段比 data 字段更重要?

Function Calling 的核心机制是:模型生成工具调用请求 → 你执行函数 → 把结果喂回模型 → 模型生成最终回复。这个流程里,绝大多数开发者只关注“怎么把 data 传回去”,却忽略了 返回结构本身就是一种协议 。在天气 API 场景, {"temperature":25,"humidity":60} 足够了;但在金融场景, {"last_price":"1789.50","timestamp":1779825600000} 这样的裸数据,等于把炸弹交给模型去拆。

我见过最危险的案例,是某量化团队把 TickDB 的原始响应直接 json.dumps() 后塞给模型。当 API 因限流返回 {"code":3001,"message":"Rate limited"} ,模型在第二轮调用中,把 "Rate limited" 当作价格字符串,生成回复:“茅台当前价格为 Rate limited 元”。这不是模型幻觉,是你没给它定义“什么是有效数据”。

2.1 统一返回结构:success/data/error_code 三元组的工程价值

我在生产代码中强制所有工具函数返回一个严格结构:

{
    "success": True or False,
    "data": [...],  # 仅当 success=True 时存在且非空
    "error_code": ""  # 仅当 success=False 时存在,值为机器可读字符串
}

这个设计不是为了好看,而是解决三个实际问题:

第一,阻断错误信息污染。 get_ticker 遇到限流,函数内部捕获 resp.status_code == 429 ,不返回原始 JSON,而是构造 {"success":False,"error_code":"RATE_LIMITED"} 。模型看到 success=False ,就会触发 SYSTEM_PROMPT 第二条规则:“工具返回 success=false 时,直接告知用户‘当前无法获取行情数据,错误原因:<error_code>’,不要猜测或编造任何数值。” 这样,用户收到的是明确告警,而不是一个胡乱编造的价格。

第二,统一错误分类,支撑监控告警。 error_code 不是随便写的字符串。我定义了一套标准化错误码:

  • RATE_LIMITED :限流,需退避重试
  • INVALID_API_KEY :密钥无效,需检查配置
  • PERMISSION_DENIED :权限不足,需升级套餐
  • EMPTY_DATA :API 返回空数组,可能是标的休市或代码错误
  • TIMEOUT :网络超时,需检查网络策略

这些 code 可以直接接入 Prometheus 告警规则。比如当 RATE_LIMITED 错误率超过 5%,自动触发 Slack 通知;当 INVALID_API_KEY 出现,立即停止该 API Key 的所有调用并邮件告警。如果返回的是原始 {"code":1004} ,你就得写一堆正则去解析,还容易漏掉新错误码。

第三,为后续扩展留出结构化空间。 比如未来要加熔断机制:当 RATE_LIMITED 在 1 分钟内出现 10 次,就自动降级到缓存数据。这个逻辑可以直接基于 error_code 字段写,不需要重新解析整个 response body。

2.2 Decimal 类型保护:为什么浮点数字符串必须显式转 Decimal?

看这段 TickDB 的真实响应:

{
  "data": [{
    "symbol": "BTCUSDT",
    "last_price": "62145.7890123456789",
    "volume_24h": "123456789012.34567890123456789"
  }]
}

last_price volume_24h 都是 高精度字符串 ,不是 float。如果你写 float(d["last_price"]) ,Python 会把它转成双精度浮点数,丢失末尾精度。对于 BTC 这种价格常带 8 位小数的标的, 62145.7890123456789 转 float 后变成 62145.78901234568 ,差了 0.00000000000000001 。单看不大,但乘以 1000 手合约,就是 0.00000000000001 BTC 的误差——在高频套利场景,这就是真金白银。

更糟的是,有些交易所返回的 volume 是科学计数法字符串 "1.23456789e+12" float() 能转,但 int() 会直接报错。而金融系统里,成交量必须是整数(单位:股/手/张),价格必须是定点数(单位:元/美元)。

解决方案是: 所有数值字段,先用 str() 强制转字符串,再用 Decimal() 构造

from decimal import Decimal, InvalidOperation

try:
    price = Decimal(str(d["last_price"]))
    vol = Decimal(str(d.get("volume_24h", "0")))
except (InvalidOperation, ValueError):
    # 跳过解析失败的条目,不中断整个响应
    continue

Decimal 的优势在于:

  • 精确表示任意精度小数,无浮点误差;
  • 支持 str(price) 安全转回字符串,避免 repr(float) 的科学计数法;
  • Decimal('0') 0.0 更符合金融语义(零是精确值,不是近似值)。

我在代码里还加了兜底: d.get("volume_24h", "0") 。因为某些冷门标的,TickDB 可能不返回 volume_24h 字段,直接取 d["volume_24h"] 会抛 KeyError 。而 get() 返回 None str(None) "None" Decimal("None") 会报 InvalidOperation ,所以 except 块能捕获并跳过。

这个细节,通用教程永远不会提。但它决定了你的 Agent 在处理加密货币行情时,是输出精确到小数点后 8 位的价格,还是输出一个四舍五入到小数点后 2 位的“大概价格”。

3. 时间戳陷阱:为什么“文档说毫秒”不等于“代码能除1000”?

这是我在 TickDB MCP 工具核验中踩过最深的坑。官方文档白纸黑字写着:“所有接口返回的时间戳字段均为毫秒(ms)”。我信了,写了全局转换函数 lambda ts: datetime.fromtimestamp(ts/1000) 。上线三天后,用户反馈:“你们的成交数据时间全错到 1970 年了”。

查日志发现, get_recent_trades 接口对 AAPL.US 返回的 timestamp 1779825600 (10 位),而对 BTCUSDT 返回的是 1779874554001 (13 位)。前者是秒级 Unix 时间戳,后者才是毫秒级。文档和实际行为的差异,让一刀切的 /1000 把 2026 年的交易时间变成了 1970 年的某个时刻。

这不是 TickDB 的 bug,而是金融数据 API 的常态。不同资产类别、不同交易所、不同数据源,对“时间戳”的定义本就不同:

  • 股票市场(NYSE/NASDAQ/HKEX)传统用秒级时间戳,因为逐笔成交频率低(毫秒级意义不大);
  • 加密货币市场(Binance/OKX)用毫秒级,因为交易频率高达每秒万笔;
  • 期货市场可能用微秒级(16 位);
  • TickDB 作为聚合层,必须兼容所有上游源,所以它暴露的是“原始时间戳”,而非“标准化时间戳”。

3.1 接口级时间戳核验:每个 endpoint 都要单独测试

我建立了一个最小化核验脚本,对每个要接入的 TickDB 接口,手动抓取真实响应,记录 timestamp 字段的位数和实际含义:

接口 品种示例 timestamp 字段名 实际值(示例) 位数 单位 来源验证方式
get_ticker 600519.SH timestamp 1779825600000 13 毫秒 UTC MCP 工具直连调用
get_kline AAPL.US (日线) time 1779782400000 13 毫秒 UTC MCP 工具直连调用
get_recent_trades AAPL.US timestamp 1779825600 10 秒级 UTC MCP 工具直连调用
get_recent_trades 700.HK timestamp 1780041599 10 秒级 UTC MCP 工具直连调用
get_recent_trades BTCUSDT timestamp 1779874554001 13 毫秒 UTC MCP 工具直连调用

这个表格不是凭空写的,而是用 Python 脚本循环调用各接口 100 次,统计 len(str(ts)) 的分布。结果发现: get_recent_trades 对股票类标的( .US , .HK , .SH )100% 返回 10 位,对加密货币( USDT , BTC )100% 返回 13 位。

所以, 时间戳处理规则必须绑定到具体接口+具体资产类别,不能全局统一 。我在 get_ticker 函数里写死注释:

# ticker 接口的 timestamp 经 MCP 实测为 13 位毫秒 UTC。
# 注意:get_recent_trades 接口中,股票(美股/港股)为 10 位秒级,
# 加密货币为 13 位毫秒。时间戳单位需逐接口、逐品种核验,不可一刀切。
ts = d["timestamp"]  # 13 位毫秒

而在 get_recent_trades 函数里,则根据 symbol 后缀动态判断:

def get_recent_trades(symbol: str) -> dict:
    # ... 请求逻辑 ...
    for trade in data.get("data", []):
        ts = trade["timestamp"]
        # 根据 symbol 后缀判断时间戳单位
        if symbol.endswith((".US", ".HK", ".SH", ".SZ", ".BJ")):
            # 股票类:10位秒级
            dt = datetime.fromtimestamp(ts, tz=timezone.utc)
        elif symbol.endswith(("USDT", "BTC", "ETH", "SOL")):
            # 加密货币:13位毫秒
            dt = datetime.fromtimestamp(ts / 1000, tz=timezone.utc)
        else:
            # 默认按毫秒处理,加告警日志
            logger.warning(f"Unknown symbol {symbol}, assuming millisecond timestamp")
            dt = datetime.fromtimestamp(ts / 1000, tz=timezone.utc)
        trade["datetime_utc"] = dt.isoformat()

这个逻辑看起来复杂,但换来的是时间精度 100% 正确。在量化回测中,时间错 1 秒,可能错过一个关键信号;错 1 毫秒,在高频场景就是生死线。

3.2 时间戳字段命名的误导性:为什么不能只看字段名?

另一个陷阱是字段名。 get_ticker 返回 timestamp get_kline 返回 time get_recent_trades 也返回 timestamp 。名字一样,单位却不同。这是因为 TickDB 的设计哲学是“保持上游源格式”,而不是“提供标准化字段”。

所以, 永远不要假设字段名相同就意味着语义相同 。你必须把每个接口的字段规范,当作独立的契约来对待。我在项目文档里专门建了一个 TIMESTAMP_RULES.md 文件,内容如下:

## 时间戳处理规范(2026-05-29 核验)

### get_ticker
- 字段名:`timestamp`
- 单位:毫秒 UTC
- 验证方式:`len(str(timestamp)) == 13`
- 示例:`1779825600000` → `2026-05-29T12:00:00.000Z`

### get_kline
- 字段名:`time`
- 单位:毫秒 UTC
- 验证方式:`len(str(time)) == 13`
- 示例:`1779782400000` → `2026-05-28T12:00:00.000Z`

### get_recent_trades
- 字段名:`timestamp`
- 单位:**依 asset type 而定**
  - 股票(`.US`, `.HK`, `.SH` 等):秒级 UTC,`len(str(timestamp)) == 10`
  - 加密货币(`USDT`, `BTC` 等):毫秒 UTC,`len(str(timestamp)) == 13`
- 验证方式:必须先解析 `symbol` 后缀,再决定除数
- 示例(AAPL.US):`1779825600` → `2026-05-29T12:00:00Z`
- 示例(BTCUSDT):`1779874554001` → `2026-05-29T23:55:54.001Z`

这份文档不是给模型看的,是给你自己和团队看的。每次新增一个接口,第一件事就是运行核验脚本,更新这个表格。它比任何代码注释都可靠,因为它是实测数据,不是主观猜测。

4. 生产部署的冷启动与限流:为什么函数计算比本地调试难十倍?

写完本地 python tongyi_tickdb.py 能跑通,只是万里长征第一步。当你把代码部署到阿里云函数计算(FC),会遇到两个本地环境完全不会出现的问题: 冷启动延迟 网络策略限制 。它们不是代码 bug,而是云原生架构的固有特性。

4.1 冷启动延迟:为什么首次调用要等 3 秒以上?

函数计算的冷启动,本质是容器初始化过程。当你第一次触发 FC 函数,系统要:

  1. 拉取 Python 3.11 运行时镜像(约 200MB);
  2. 下载你的代码包(含 dashscope==1.20.0 , requests , python-dotenv );
  3. 执行 import 语句,加载所有模块( dashscope 初始化要 800ms);
  4. 建立第一个 requests.Session 连接池。

我在实测中,FC 首次调用耗时分布如下:

  • 90% 分位:2.8 秒
  • 95% 分位:3.2 秒
  • 99% 分位:4.1 秒

而 TickDB 的 get_ticker REST API 超时设为 10 秒,DashScope 的 Generation.call 默认超时是 60 秒。如果 FC 函数超时设为 10 秒,那 90% 的首次调用都会失败。

解决方案是: 把耗时操作提到全局作用域,复用实例

# ✅ 正确:全局初始化,实例复用
import os
import json
import time
import requests
from decimal import Decimal
from dotenv import load_dotenv
from dashscope import Generation  # import 在顶层

# 全局变量,函数实例复用
DASHSCOPE_API_KEY = os.getenv("DASHSCOPE_API_KEY")
TICKDB_API_KEY = os.getenv("TICKDB_API_KEY")
TICKDB_REST_URL = "https://api.tickdb.ai"

# TOOLS 和 SYSTEM_PROMPT 是静态数据,放全局
TOOLS = [ ... ]  # 如前文定义
SYSTEM_PROMPT = """..."""  # 如前文定义

def handler(event, context):
    # ✅ 所有耗时初始化都在这里之外
    body = json.loads(event)
    user_query = body.get("query", "")
    return call_tongyi_with_fc(user_query)  # 纯逻辑函数

对比错误写法:

# ❌ 错误:每次调用都重新 import 和初始化
def handler(event, context):
    import os
    from dashscope import Generation  # 每次都 import!
    TOOLS = [...]  # 每次都构建!
    SYSTEM_PROMPT = "..."  # 每次都赋值!
    # ... 其他逻辑

import 在 Python 中不是零成本操作。 from dashscope import Generation 会触发整个 SDK 的模块加载和初始化,包括 HTTP 客户端配置、重试策略设置等。放在 handler 内,每次调用都要重复一遍;放在全局,只在容器启动时执行一次,后续调用直接复用。

同样, TOOLS 是一个嵌套字典,JSON 序列化后约 1.2KB。如果每次调用都 json.dumps(TOOLS) ,CPU 就浪费在字符串拼接上。而全局定义后,DashScope SDK 内部会缓存它的序列化结果。

4.2 限流退避:为什么 Retry-After 头必须精确解析?

TickDB 的限流策略是:当请求超过配额,返回 HTTP 429 状态码,并在响应头中带上 Retry-After: 3.5 。这个 3.5 不是整数秒,而是浮点数,表示“3.5 秒后可重试”。

通用教程里,限流处理常写成 time.sleep(1) time.sleep(5) 。但在金融场景,这会导致两个问题:

  • 过度等待 :如果 Retry-After 0.1 ,你却睡 5 秒,吞吐量直接降为 1/50;
  • 等待不足 :如果 Retry-After 5.0 ,你只睡 1 秒,重试请求仍会 429,形成死循环。

所以,我的 get_ticker 函数里,限流处理是这样的:

if resp.status_code == 429 or data.get("code") == 3001:
    retry_after = resp.headers.get("Retry-After", "5")
    try:
        wait_seconds = float(retry_after)  # 精确转浮点
    except (ValueError, TypeError):
        wait_seconds = 5  # 降级为 5 秒
    time.sleep(wait_seconds)  # 精确等待
    return get_ticker(symbols_str, retry_count + 1)

关键点:

  • resp.headers.get("Retry-After", "5") :优先读响应头, fallback 到默认值;
  • float(retry_after) :支持 3.5 0.1 等任意浮点;
  • time.sleep(wait_seconds) :不四舍五入,不取整,精确等待。

我还加了最大重试次数 MAX_RETRIES = 3 。因为如果 Retry-After 300 (5 分钟),你重试 3 次就要等 15 分钟,用户早关页面了。此时返回 {"success":False,"error_code":"MAX_RETRIES_EXCEEDED"} ,让模型告诉用户“服务暂时繁忙,请稍后再试”,比卡死强。

这个逻辑,本地调试时很难触发,因为本地 IP 不受限流。但一上 FC,多个函数实例共享同一个公网出口 IP,限流概率陡增。我在压力测试中,用 10 个并发请求 get_ticker ,30% 的请求在第二轮就触发了 429。没有这套退避逻辑,整个服务就不可用。

5. 从代码到产品:为什么金融场景的 Function Calling 是一场持续校验?

写完 tongyi_tickdb.py ,跑通所有单元测试,甚至压测通过,这不叫完成。在金融数据领域,Function Calling 的交付终点,是 建立一套可持续的校验闭环 。因为市场在变、API 在变、模型也在变。今天跑通的代码,明天可能因一个上游变更而失效。

5.1 MCP 工具核验:为什么必须自己写脚本验证文档?

TickDB 文档说“所有时间戳为毫秒”,但我用 MCP 工具(Model Calling Protocol)直连调用,发现 get_recent_trades 对股票返回秒级。这个差异不是偶然,而是必然——文档描述的是“设计目标”,而 MCP 核验看到的是“运行事实”。

MCP 工具的本质,是一个轻量级的、面向 AI Agent 的 CLI。它不经过通义千问,直接调用你的函数,把输入参数、执行过程、返回结果全部打印出来。我写的 mcp_verify.py 长这样:

# mcp_verify.py
import json
from tongyi_tickdb import get_ticker, get_recent_trades

def verify_ticker():
    print("=== get_ticker 核验 ===")
    result = get_ticker("600519.SH,AAPL.US")
    print(f"返回 success: {result['success']}")
    if result['success']:
        for d in result['data'][:2]:  # 只打前两条
            print(f"  {d['symbol']}: last_price={d['last_price']}, "
                  f"timestamp_ms={d['timestamp_ms']} (len={len(str(d['timestamp_ms']))})")

def verify_trades():
    print("\n=== get_recent_trades 核验 ===")
    # 测试股票
    result_stock = get_recent_trades("AAPL.US")
    if result_stock['success']:
        ts = result_stock['data'][0]['timestamp']
        print(f"  AAPL.US timestamp: {ts} (len={len(str(ts))})")
    # 测试加密货币
    result_crypto = get_recent_trades("BTCUSDT")
    if result_crypto['success']:
        ts = result_crypto['data'][0]['timestamp']
        print(f"  BTCUSDT timestamp: {ts} (len={len(str(ts))})")

if __name__ == "__main__":
    verify_ticker()
    verify_trades()

每周一上午,我雷打不动运行这个脚本,把输出保存为 mcp_report_20260529.txt 。如果某天发现 AAPL.US len 变成了 13,我就知道 TickDB 更新了股票 trades 接口,必须立刻检查代码里的分支逻辑是否还正确。

这个习惯救了我两次:一次是 TickDB 悄悄把 get_kline time 字段从秒级升级为毫秒级,我提前一天发现并更新了转换逻辑;另一次是 DashScope SDK 从 1.19 升级到 1.20, tool_calls 的返回结构微调,我通过 MCP 日志快速定位到 assistant_output.tool_calls[0].id 的访问方式变了。

5.2 日志结构化:为什么 error_code 要进 ELK,而 not data?

生产环境的日志,不是用来“看”的,是用来“查”的。我所有的工具函数,都用 structlog 输出结构化日志:

import structlog

logger = structlog.get_logger()

def get_ticker(symbols_str: str, retry_count: int = 0) -> dict:
    logger.info("get_ticker_start", symbols=symbols_str, retry_count=retry_count)
    try:
        # ... 执行逻辑 ...
        if success:
            logger.info("get_ticker_success", 
                       symbols=symbols_str, 
                       data_count=len(results),
                       first_symbol=results[0]["symbol"])
            return {"success":True, "data":results, "error_code":""}
        else:
            logger.error("get_ticker_failure", 
                        symbols=symbols_str, 
                        error_code=error_code,
                        status_code=resp.status_code if 'resp' in locals() else None)
            return {"success":False, "data":[], "error_code":error_code}
    except Exception as e:
        logger.exception("get_ticker_exception", symbols=symbols_str, error=str(e))
        return {"success":False, "data":[], "error_code":"UNEXPECTED_ERROR"}

关键设计:

  • logger.info 记录成功路径,带 data_count first_symbol ,方便快速确认数据量;
  • logger.error 记录失败路径,带 error_code status_code ,这是告警的唯一依据;
  • logger.exception 记录未捕获异常,带完整 traceback。

这些日志被 Filebeat 采集,打入 ELK(Elasticsearch + Logstash + Kibana)。我建了几个核心看板:

  • 错误码分布图 :柱状图展示 RATE_LIMITED INVALID_API_KEY 等 error_code 的占比,一眼看出主要瓶颈;
  • 成功率趋势图 :折线图显示 success=True 的比例,低于 99.5
Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐