一句话摘要:在 stock-mcp-gateway 上两天内开发了持仓风险诊断、相关性矩阵、组合全景报告、调仓建议、个股评分、市场总览六个组合工具。本文拆解开发中遭遇的六个真实坑——从并行缓存竞态到跨市场日期对齐——以及每个坑的修复方案。

1. 缘起 — 工具是做不完的

我的 stock-mcp-gateway 是一个 Cloudflare Workers 上的 MCP(Model Context Protocol)网关,提供股票行情、技术分析、资金流向等基础数据工具集。每个工具职责单一、数据源明确——get_realtime_quote 负责行情,get_technical_analysis 负责技术指标,get_kline 负责 K 线数据。

但用户的需求从来不是”给我茅台的历史 K 线”,而是”我的持仓怎么样?该不该调仓?”。

这就意味着我需要组合工具(Composite Tools)——它们不直接从 API 抓原始数据,而是调多个已有工具/API,将数据聚合、计算、分析后返回结构化报告。

2026 年 7 月 27 日到 28 日,我集中开发了 6 个组合工具:

工具 功能 数据源数量
portfolio_risk_diagnosis 持仓风险诊断 3(行情+名称+板块)
portfolio_correlation 持仓相关性矩阵 2(K线+名称)
portfolio_full_report 组合全景报告 4(以上全部+成本数据)
portfolio_rebalance 组合调仓建议 调用前两个工具
stock_score 个股综合评分 3(行情+技术+资金)
market_overview 全市场总览 3(指数+北向+行业)

听起来就是 Promise.all 一把梭的活儿?实际开发中的坑一个接一个。下面是我遭遇的六个真实问题。


2. 坑一:并行缓存竞态 — 价格全变成了 0

问题现象portfolio_risk_diagnosis 工具返回的持仓中,部分股票当前价格为 0,导致盈亏计算全部错误。

排查过程:这些股票明明有实时行情数据。单独调用 get_realtime_quote("600519") 返回正常。但组合工具里读出来就是 0。

根因:我在 fetchPositionQuote() 函数里加了一个工具层缓存:

1
2
3
4
5
6
7
8
9
// ❌ 错误写法 — 工具层缓存导致竞态
async function fetchPositionQuote(code: string): Promise<RealtimeQuote | null> {
const cacheKey = makeCacheKey("portfolio_quote", code);
const cached = getCache(cacheKey);
if (cached) return cached as RealtimeQuote;
const quote = await tencent.getRealtimeQuote(code);
setCache(cacheKey, quote, TTL_REALTIME);
return quote;
}

Promise.all 同时发起 N 个 fetchPositionQuote 调用时,如果前一个 batch 的 resolveName() 内部也调用了 tencent.getRealtimeQuote(通过 getStockInfo),它写入缓存时的 key 格式不同。于是当前 batch 的 fetchPositionQuote 读到了由另一个函数写入的、不同 key 格式的缓存条目,解析失败 → 返回 0。

修复方案:去掉工具层缓存。tencent.getRealtimeQuote() 自身已经有内部缓存(15 秒 TTL,由 src/cache.ts 管理),工具层再加一层不仅是重复,还引入了竞态风险。

1
2
3
4
5
6
7
8
// ✅ 正确写法 — 依赖源模块的内置缓存
async function fetchPositionQuote(code: string): Promise<RealtimeQuote | null> {
try {
return await tencent.getRealtimeQuote(code); // tencent.ts 自己管缓存
} catch {
return null; // 单只失败不影响其他
}
}

教训:缓存不是越多越好。每层缓存都有自己的 key 空间和生命周期,叠起来就是竞态温床。让数据源模块管理自己的缓存,组合工具只做编排不自己做缓存。


3. 坑二:Cloudflare Workers 累积超时

问题现象:当持仓数量 >= 8 只时,portfolio_correlation 工具在 Cloudflare Workers 上超时(30 秒 CPU 限制)。

排查过程portfolio_correlation 需要拉取每只持仓的 K 线数据 + 每只持仓的名称。最开始的实现是:

1
2
3
4
5
6
7
8
9
10
11
// ❌ 错误写法 — 串行叠加
const klineResults = [];
for (const code of codes) {
klineResults.push(await fetchKline(code, days)); // 串行,每个 ~200ms-2s
}
const names = [];
for (const code of codes) {
names.push(await resolveName(code)); // 串行,每个 ~100ms
}
// 总时间 ≈ N × (kline_time + name_time)
// N=8 → 至少 8 × 300ms = 2.4s,N=15 → 4.5s+

看起来还好?不对——fetchKline 冷启动可能 2 秒(60 天 K 线首次拉取),8 只 × 2.2s + 8 × 100ms ≈ 18.4 秒,加上其他数据处理,轻松超过 25 秒 MCP 超时。

修复方案:采用混合并发模式——把轻量级请求(名称查询)提前发起,让它们在后台跑,同时串行执行重量级请求(K 线):

1
2
3
4
5
6
7
8
9
10
11
12
// ✅ 正确写法 — 轻量并发 + 重量串行
// 1. 先发起轻量级并行请求(不 await)
const namePromises = codes.map(c => resolveName(c));

// 2. 再执行重量级串行请求(K线,需要避免 API 限流)
const klineResults = [];
for (const code of codes) {
klineResults.push(await fetchKline(code, days));
}

// 3. 最后收集并行结果
const names = await Promise.all(namePromises);

为什么这有效:JavaScript 的事件循环在 await 阻塞时会继续执行已开始的 Promise。当第一个 fetchKline 在等待网络响应时,resolveName 的 Promise 已经在事件循环中推进了。到串行循环结束时,name 查询往往已经完成。总时间 ≈ N_serial × per_call + max(parallel_time, 0),而不是 N_serial × per_call + parallel_time。

教训:不要把所有 await 写在一起。能并行的先发起(不 await),必须串行的后执行(await),最后收集并行结果——这个顺序对总延迟有质的影响。


4. 坑三:跨市场日期对齐

问题现象portfolio_correlation 计算出的相关性矩阵中,A 股和港美股的相关性经常为 NaN

排查过程:持仓包含 A 股(159949)、港股(00700)、美股(AAPL)。三个市场的交易历完全不同:

  • A 股:春节、国庆休市
  • 港股:春节 + 复活节 + 佛诞日
  • 美股:感恩节、圣诞

我最初的做法是把所有交易日的收盘价拼在一起,但某只股票某天没交易就填 null → 后续 Pearson 相关系数计算时 null 传播 → 全部 NaN。

修复方案:按共同交易日对齐——只保留所有持仓都有交易数据的日期:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 从各持仓的 K 线构建日期→价格映射
const dateMap: Map<string, number[]> = new Map();

for (const result of klineResults) {
for (const rec of result.records) {
if (!dateMap.has(rec.date)) {
dateMap.set(rec.date, new Array(codes.length).fill(NaN));
}
const idx = codes.indexOf(result.code);
dateMap.get(rec.date)![idx] = rec.close;
}
}

// 只保留所有股票都有数据的日期
const alignedDates: number[][] = [];
for (const [, prices] of dateMap) {
if (prices.every(p => !isNaN(p) && p > 0)) {
alignedDates.push(prices);
}
}

一个验证数据:用对齐后的数据计算,茅台和五粮液的 Pearson 相关系数 r = +0.81(同行业白酒,高度相关),而茅台和宁德时代 r = +0.23(不同行业,弱相关)。

教训:跨市场分析中,”数据对齐”不是技术细节,而是正确性的前提。不对齐就计算,结果全是噪声。


5. 坑四:单只股票失败拖垮整个组合

问题现象:当某只股票临时停牌或数据源返回异常时,整个 portfolio_risk_diagnosis 工具报错。

排查过程Promise.all 的默认行为是所有或全无(fail-fast)——任何一个 promise reject,整个 Promise.all 就 reject。

1
2
3
4
5
6
7
8
// ❌ 错误写法 — 一个失败全部崩
const enriched = await Promise.all(
rawHoldings.map(async (h) => {
const quote = await tencent.getRealtimeQuote(h.code); // 某只股票挂了 → 全部挂
const name = await resolveName(h.code);
return { code: h.code, current_price: quote.price, name };
})
);

修复方案:每只股票的抓取链各自 try/catch,单只失败时返回含默认值的占位对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// ✅ 正确写法 — 逐只隔离
const enriched = await Promise.all(
rawHoldings.map(async (h) => {
try {
const [quote, name, sector] = await Promise.all([
fetchPositionQuote(h.code),
resolveName(h.code),
resolveSector(h.code, market.market_code),
]);
return buildPosition(h, quote, name, sector);
} catch {
// 单只失败:返回占位,不阻塞整体
return {
code: h.code,
shares: h.shares,
current_price: 0,
profit_loss_pct: 0,
error: "数据获取失败",
};
}
})
);

教训:组合工具应遵守局部失败不全局崩溃原则。用 try/catch 包裹每个子任务,让调用方看到”数据缺失”而不是”500 错误”。


6. 坑五:source=bridge 的死胡同

问题现象portfolio_full_report 工具需要从 Bridge(本地服务)获取持仓的成本数据,但在 Cloudflare Workers 上无法访问 localhost

排查过程fetchHoldingsFromBridge() 在网关代码中试图请求 http://localhost:9877/config/holdings。在本地开发环境没问题,部署到 Cloudflare Workers 后——localhost 指向 Workers 运行环境的本地,不是我的服务器。连接被拒绝。

修复方案:采用两步本地流程(two-step local flow):

  1. 本地脚本~/.hermes/scripts/portfolio-daily-briefing.py)在本地服务器运行,调用 Bridge 获取成本数据
  2. 脚本将数据传给 Gateway,再调用组合工具生成报告
  3. 整个流程通过 Hermes cron 在本地执行,no_agent=true

这个模式是:Worker 只读取公网可访问的数据,本地私有数据在本地先准备好再传给 Worker

教训:Cloudflare Workers 是边缘环境,没有”本地网络”。任何需要访问内网服务的数据流都要提前设计——要么走 Webhook 反向推送,要么走本地脚本编排。


7. 坑六:工具级超时保护

问题现象portfolio_correlation 在处理 15+ 只持仓、60 天 K 线时,偶尔超过 Cloudflare Workers 的 30 秒 CPU 时间限制,返回 524 错误。

排查过程:MCP 协议本身没有工具级超时——一次 tools/call 可以永远等下去。但 Workers 平台有硬限制(免费计划 30 秒 CPU,付费计划 60 秒)。超过限制不会返回 MCP 错误码,而是 HTTP 524(超时)。

修复方案:用 Promise.race 实现工具级超时保护:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const CORRELATION_TIMEOUT = 50000; // 50 秒

export async function calcPortfolioCorrelation(
codes: string[], days: number
): Promise<PortfolioCorrelationReport> {
return executeWithTimeout(doCalcCorrelation(codes, days), CORRELATION_TIMEOUT);
}

async function executeWithTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
let timer: ReturnType<typeof setTimeout>;
const timeout = new Promise<T>((_, reject) => {
timer = setTimeout(() => reject(new Error(`执行超时 (${ms}ms)`)), ms);
});
try {
return await Promise.race([promise, timeout]);
} finally {
clearTimeout(timer);
}
}

关键设计决策:

  • 核心逻辑提取到 doCalcCorrelation(),让超时包装干净分离
  • portfolio_full_report 中,相关性计算失败时不阻塞主报告——只把相关性部分设默认值
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 在 full report 中:相关性失败不阻塞主报告
let correlationResult: PortfolioCorrelationReport | null = null;
if (codes.length >= 2) {
try {
correlationResult = await executeWithTimeout(
doCalcCorrelation(codes, correlationDays), 50000
);
} catch {
correlationResult = null; // 优雅降级
}
}

return {
summary: riskResult.summary,
concentration: riskResult.concentration,
positions: riskResult.positions,
correlation: {
avg_correlation: correlationResult?.avg_correlation ?? 0,
diversification_score: correlationResult?.diversification_score ?? 100,
matrix: correlationResult?.matrix ?? [],
},
risk_flags: riskResult.risk_flags,
};

教训:在受限环境(Workers、Lambda、Deno Deploy)中,每个组合工具都应该有自己的超时保护。而且超时要优雅降级——宁可返回”相关性数据暂缺”,也不要抛 500 让用户什么都看不到。


8. 总结 — 组合工具的本质是编排

两天开发六个组合工具,真正的代码逻辑其实很简单——查数据、算指标、组合结果。难的不是写代码,是让代码在边缘环境中稳定运行。

几个核心 takeaway:

  1. 不要做自己的缓存 — 依赖数据源模块的缓存,工具层只做编排。层叠缓存是竞态的温床。
  2. 轻量先并行,重量后串行 — 利用 JS 事件循环的特性,在 await 重量操作时让轻量操作在后台推进。总时间降到接近最慢的串行操作。
  3. 局部失败不全局崩溃 — 每条数据链单独 try/catch,用占位对象替代抛异常。
  4. 跨市场先对齐日期 — 不对齐的计算结果全是噪声。
  5. 边缘环境没有”本地” — 内网数据走本地脚本编排,Worker 只访问公网。
  6. 每个工具都要有超时保护 — 优雅降级优于 HTTP 524。

这些工具上线后,每天收盘后自动跑一次组合诊断,生成持仓风险报告。整个过程约 6-8 秒(含缓存预热),用户可以收到带结构化风险标记的飞书卡片。


相关链接