1. 项目概述:当模型调用失败成为常态,我选择把“翻车现场”变成可复用的 Skill
“接模型翻车后,我用WorkBuddy把它做成 Skill:20天,15轮迭代实录”——这个标题不是营销话术,而是我过去三周真实的工作日志标题。它背后没有玄学,只有一连串具体到毫秒级的报错、反复重试的 API 请求、被 OpenRouter 拒绝的第7次 auth header 校验,以及最终在 WorkBuddy 工作台里稳定运行的、带输入校验+自动降级+结果缓存的数学建模 Skill。很多人看到“WorkBuddy”“Skill”“OpenRouter”这些词,第一反应是“又一个低代码平台玩具”,但真正用过的人知道:WorkBuddy 的 Skill 机制不是封装按钮,而是一套轻量级、可调试、可版本化、能嵌入真实工作流的函数式执行单元。它不替代模型开发,但彻底改变了模型能力落地的方式——你不再需要为每次调用写一遍 curl 命令、处理一遍 rate limit、再手动 parse 一遍 JSON response;你只需要定义好输入契约、输出契约、错误兜底逻辑,剩下的交给 Skill 运行时。这正是我决定把“翻车”过程本身作为训练素材的原因:每一次 401 Unauthorized、每一次 timeout、每一次 malformed JSON,都是对真实服务边界的测绘。我把这 15 轮迭代拆解成 20 天的每日记录,不是为了展示“我多能熬”,而是想说清楚一件事:一个能在生产环境里扛住用户乱输、网络抖动、模型退化、API 变更的 Skill,它的健壮性不是写出来的,是被现实一拳一拳打出来的。如果你正在用 OpenRouter 或类似中继服务对接 LLM,正被 token 有效期、模型别名变更、响应结构漂移这些问题反复消耗精力,那么这篇实录里的每一个参数配置、每一行日志分析、每一次 fallback 策略调整,都来自真实压测场景,可以直接抄作业。
2. 整体设计思路与方案选型逻辑:为什么是 WorkBuddy + Skill,而不是自己搭 API 网关?
2.1 不是“平台选型”,而是“能力交付路径”的重新定义
一开始我也试过纯自建方案:用 FastAPI 写个中间层,加 Redis 缓存、加 Sentry 监控、加 Prometheus 指标暴露。两周后我删掉了 80% 的代码。原因很实在:我要交付的不是一个“API 服务”,而是一个“能被非技术人员在 Excel 里调用的数学建模功能”。我的终端用户是财务部同事,他们需要输入一组销售数据和季节系数,点击一个按钮,得到下季度预测区间和置信度提示。他们不关心你是用 Anthropic 还是 Groq,不关心 token 是怎么续期的,只关心“点下去,3 秒内出结果,错了有中文提示”。WorkBuddy 的 Skill 机制恰好卡在这个交点上——它强制你以“输入-处理-输出”三段式定义能力,天然隔离了底层实现复杂度;它的 UI 配置面板让非技术同事能自主修改超时阈值、切换备用模型;它的版本管理让你能把“v1.2(修复 GML 模型日期解析 bug)”直接推送给业务方测试。这不是妥协,而是聚焦:把 70% 的工程精力从“让服务跑起来”转移到“让业务方用得稳”。
2.2 为什么放弃直接调用 OpenAI/Anthropic 官方 SDK?
OpenRouter 是这次项目的基础设施级选择,但它不是因为“便宜”或“免费”——而是因为它提供了统一的抽象层。我们实际接入了 4 类模型:OpenAI 的 gpt-4o-mini(主用)、Anthropic 的 claude-3-haiku(备用)、Google 的 gemini-2.0-flash(长文本兜底)、以及本地部署的 Qwen2.5-7B(离线验证)。如果直接用各家 SDK,意味着你要维护 4 套认证逻辑(API Key、Bearer Token、X-API-Key)、4 种 rate limit 策略(每分钟请求数 vs 每分钟 token 数)、4 种响应结构(OpenAI 的 choices[0].message.content vs Anthropic 的 content[0].text)。而 OpenRouter 的统一接口(POST /v1/chat/completions)把所有这些差异收口到一个 schema 里。更重要的是,它的 model alias 机制允许我们把 “math-model-prod” 这个逻辑名映射到具体 provider,当某家模型临时不可用时,只需在 OpenRouter 控制台改一行映射,WorkBuddy Skill 完全无感。这种解耦带来的运维效率提升,远超任何 SDK 封装的便利性。
2.3 Skill 架构的三层分层设计:输入层、执行层、适配层
整个 Skill 并非单文件脚本,而是按职责严格分层的三个模块:
输入层(Input Validator):负责接收 WorkBuddy 传入的原始 JSON,做字段存在性检查、数值范围校验(如销售数据不能为负)、格式标准化(统一时间戳为 ISO8601)。这里我放弃了正则硬匹配,改用 Pydantic v2 的 BaseModel 定义 Schema,并开启 strict mode。实测发现,当用户粘贴 Excel 数据时,常出现末尾空格、科学计数法误读等问题,Pydantic 的 coerce 功能能自动处理 90% 的脏数据,比手写 if-else 清晰十倍。
执行层(Executor):核心逻辑所在。它不直接发 HTTP 请求,而是调用一个封装好的
ModelClient类。这个类内部实现了:自动重试(指数退避,最大 3 次)、token 自动刷新(监听 401 响应并触发 refresh flow)、响应结构归一化(把不同 provider 的 response 提取为统一的{“result”: str, “confidence”: float, “model_used”: str}结构)。关键点在于:所有网络操作都设定了硬超时(connect=5s, read=15s),且超时后立即进入降级流程,绝不阻塞主线程。适配层(Output Adapter):将执行层返回的标准化结构,转换为 WorkBuddy 要求的特定格式。WorkBuddy 的 Skill 输出必须是 JSON,且要求顶层 key 为
output,子 key 必须与你在 UI 中声明的“输出字段”完全一致。这里我写了专用的 adapter,当 confidence < 0.6 时,自动追加warning: "预测置信度偏低,建议人工复核"字段;当模型返回空结果时,不抛异常,而是返回{"result": "N/A", "confidence": 0.0}—— 因为业务方明确要求“宁可给默认值,也不能报错中断流程”。
这三层之间通过明确的 interface 耦合,每个模块可独立单元测试。比如输入层的测试用例就覆盖了 12 种典型脏数据场景(空字符串、NaN、超长数字、中文逗号分隔等),确保问题在入口就被拦截。
3. 核心细节解析与实操要点:从第一轮“裸奔调用”到第十五轮“生产就绪”
3.1 第1轮:最简可行版(MVP)——暴露所有问题的起点
第一版 Skill 只有 23 行代码:读取输入、拼接 OpenRouter 的 curl 命令、执行、返回 raw response。它成功运行了,但也立刻暴露出 5 个致命问题:
API Key 泄露风险:我把 OpenRouter 的 API Key 写死在 Skill 代码里,Push 到 Git 后被安全扫描工具标红。WorkBuddy 的 Secret Management 机制要求你把敏感信息存为 Environment Variable,然后在代码中用
os.getenv("OPENROUTER_API_KEY")读取。但要注意:WorkBuddy 的 env var 是全局的,不同 Skill 共享同一组变量,所以必须约定命名规范,比如OPENROUTER_MATH_SKILL_KEY。无错误处理:当 OpenRouter 返回 429(rate limit)时,Skill 直接 crash,WorkBuddy 控制台显示红色 error log,用户看到的是“Skill 执行失败”。正确的做法是捕获
requests.exceptions.HTTPError,判断 status code,对 429 返回友好的"系统繁忙,请稍后再试",对 400 返回"输入数据格式错误,请检查数值范围"。响应结构不兼容:OpenRouter 的 response 是标准 OpenAI 格式,但 WorkBuddy 的 UI 配置里,我定义的输出字段叫
forecast_result,而 raw response 里是choices[0].message.content。不做转换,UI 就显示空值。超时不可控:没设 timeout,某次 Anthropic 模型响应慢,Skill 卡住 47 秒,WorkBuddy 自动 kill 进程,用户界面卡死。
无日志追踪:所有 debug 都靠 print,但 WorkBuddy 的日志系统只捕获 stdout/stderr,且不带时间戳和 trace_id,排查时像大海捞针。
提示:WorkBuddy 的 Skill 日志默认只保留最近 100 条,且不支持自定义 log level。我后来在代码开头加了
import logging; logging.basicConfig(level=logging.INFO),并用logging.info(f"Input validated: {cleaned_input}")替代 print,这样日志能被完整捕获,且带时间戳。
3.2 第5轮:引入重试与降级——让 Skill 学会“喘口气”
第5轮的核心目标是解决“单点故障”。我观察到 OpenRouter 的 uptime 是 99.2%,看似很高,但对我们每天 200+ 次调用来说,意味着平均每天有 1~2 次失败。单纯重试不够,必须有策略:
重试策略:使用
tenacity库(WorkBuddy 默认支持 pip install),配置为stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=10)。这意味着第一次失败后等 1 秒,第二次失败后等 2 秒,第三次失败后等 4 秒,总等待时间不超过 7 秒。为什么不是固定 2 秒?因为网络抖动通常是瞬时的,指数退避能避免雪崩式重试。降级策略:当重试 3 次仍失败,或遇到 503(Service Unavailable)时,不报错,而是切换到备用模型。我在
ModelClient里预置了两个 endpoint:主用https://openrouter.ai/api/v1/chat/completions(指向 gpt-4o-mini),备用https://openrouter.ai/api/v1/chat/completions(指向 claude-3-haiku)。注意:不是换 URL,而是换请求 body 里的model字段值。这样切换成本最低,且 OpenRouter 保证了两个模型的 response schema 一致。熔断机制:这是第12轮才加入的。当 5 分钟内失败率超过 60%,自动触发熔断,后续请求直接走本地规则引擎(用预设的线性回归公式计算),持续 5 分钟。熔断状态存在 Redis(WorkBuddy 内置),避免重启 Skill 后状态丢失。
实测效果:在一次 OpenRouter 的区域性网络故障中(持续 18 分钟),我们的 Skill 无感知切换到备用模型,用户侧零投诉,后台监控显示失败率从 100% 降到 0%,只是响应时间平均增加了 1.2 秒。
3.3 第9轮:输入校验的精细化——从“能跑”到“防呆”
早期的输入校验只有if not input_data:,这远远不够。财务部同事常犯的错误包括:
- 把“2024Q1”写成“2024-Q1”或“2024年第一季度”
- 销售数据列里混入文字“暂无”或“-”
- 季节系数总和不等于 1.0(应为 0.98~1.02)
我用 Pydantic 重构了输入模型:
from pydantic import BaseModel, Field, field_validator from typing import List, Optional class MathModelInput(BaseModel): sales_data: List[float] = Field(..., min_items=3, description="至少3期销售数据") season_factors: List[float] = Field(..., min_items=4, max_items=4, description="4个季度系数") forecast_period: int = Field(..., ge=1, le=12, description="预测月数,1-12") @field_validator('sales_data') def no_negative_sales(cls, v): if any(x < 0 for x in v): raise ValueError('销售数据不能为负数') return v @field_validator('season_factors') def season_sum_close_to_one(cls, v): total = sum(v) if abs(total - 1.0) > 0.02: raise ValueError(f'季节系数总和应接近1.0,当前为{total:.3f}') return v关键点在于Field(..., ge=1, le=12)和@field_validator的组合。ge/le是基础范围检查,@field_validator处理业务逻辑强约束。Pydantic 会自动把输入 JSON 转为这个 Model 实例,校验失败时抛出ValidationError,我在外层 catch 它,提取e.errors()里的msg字段,组装成"输入错误:季节系数总和应接近1.0,当前为0.923"返回给用户。比 generic error 友好十倍。
3.4 第13轮:结果缓存与一致性——避免“同输入不同输出”
数学建模有个特点:相同输入,理论上应有相同输出。但 LLM 的随机性(temperature=0.3)会导致结果漂移。业务方无法接受“上午算出来是 125 万,下午算出来是 132 万”。解决方案是两级缓存:
本地内存缓存(LRU):用
functools.lru_cache(maxsize=128)缓存最近 128 次计算结果。适用于高频重复查询(如测试阶段反复输入同一组数据)。Redis 持久化缓存:对每个输入生成唯一 hash(
hashlib.md5(json.dumps(input_dict, sort_keys=True).encode()).hexdigest()),作为 Redis key,value 存{"result": "...", "timestamp": "...", "model": "..."}。设置 TTL 为 24 小时,因为销售数据通常按日更新。
缓存命中时,直接返回缓存结果,并在 response 里加"cached": true字段,方便前端做视觉提示(如显示“缓存结果,最后更新于 14:22”)。缓存未命中时,走正常模型调用,并在成功后写入 Redis。这里有个坑:WorkBuddy 的 Skill 运行时是无状态的,每次调用都是新进程,所以lru_cache在单次调用内有效,但跨调用无效——这正好符合预期,因为我们需要的是跨请求缓存,不是单请求内缓存。
4. 实操过程与核心环节实现:从环境配置到上线发布的完整链路
4.1 WorkBuddy 环境准备与 Skill 创建
WorkBuddy 的 Skill 创建流程非常直观,但有几个关键配置点新手容易忽略:
Runtime 选择:必须选
Python 3.11(不是 3.10 或 3.12)。因为 WorkBuddy 的底层沙箱基于 Ubuntu 22.04,其默认 Python 是 3.11,选其他版本会导致 pip install 失败或二进制依赖不兼容。我在第3轮就栽在这里:选了 3.12,安装tenacity时报ModuleNotFoundError: No module named 'setuptools',降级到 3.11 后解决。Dependencies 配置:在 Skill 设置页的 “Dependencies” 栏,填入
requests==2.31.0 tenacity==8.2.3 pydantic==2.7.1 redis==4.6.0。注意:- 必须指定精确版本号(
==),不能用>=。因为 WorkBuddy 的 pip install 是 --no-deps 模式,不处理依赖树,版本冲突由你全权负责。 redis库是可选的,但如果你要用 Redis 缓存,就必须显式声明。WorkBuddy 内置的 Redis 连接字符串是redis://localhost:6379/0,无需额外配置。
- 必须指定精确版本号(
Environment Variables:点击 “Add Environment Variable”,创建
OPENROUTER_API_KEY,值为你在 OpenRouter 官网获取的密钥。切记不要勾选 “Show value in logs”,否则密钥会明文出现在日志里。WorkBuddy 的 env var 是 base64 编码存储的,相对安全。Input/Output Schema 定义:这是 Skill 与外部交互的契约。在 UI 中,我定义了:
- Input Fields:
sales_data(type: array of number),season_factors(array of number),forecast_period(number) - Output Fields:
forecast_result(string),confidence_score(number),model_used(string),cached(boolean) 定义后,WorkBuddy 会自动生成 JSON Schema,你可以在代码里用它做初步校验,但强烈建议用 Pydantic 做二次校验,因为 UI 定义的 schema 不支持业务逻辑校验(如“季节系数总和必须为1”)。
- Input Fields:
4.2 核心代码实现:ModelClient 与 Skill 主函数
以下是经过 15 轮迭代后的核心代码片段,已脱敏并注释关键逻辑:
import os import json import time import hashlib import logging import requests from typing import Dict, Any, Optional from pydantic import ValidationError from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type from redis import Redis # 初始化日志和 Redis logging.basicConfig(level=logging.INFO) redis_client = Redis(host='localhost', port=6379, db=0, decode_responses=True) class ModelClient: def __init__(self): self.base_url = "https://openrouter.ai/api/v1/chat/completions" self.api_key = os.getenv("OPENROUTER_API_KEY") self.headers = { "Authorization": f"Bearer {self.api_key}", "HTTP-Referer": "https://workbuddy.example.com", # OpenRouter 要求 "X-Title": "Math Modeling Skill" # OpenRouter 要求 } # 预置主备模型 self.primary_model = "openai/gpt-4o-mini" self.fallback_model = "anthropic/claude-3-haiku" def _generate_cache_key(self, input_dict: Dict[str, Any]) -> str: """生成输入的唯一 cache key""" sorted_json = json.dumps(input_dict, sort_keys=True) return hashlib.md5(sorted_json.encode()).hexdigest() def _get_from_cache(self, cache_key: str) -> Optional[Dict[str, Any]]: """从 Redis 获取缓存""" try: cached = redis_client.get(cache_key) if cached: logging.info(f"Cache hit for key {cache_key[:8]}...") return json.loads(cached) except Exception as e: logging.warning(f"Cache get failed: {e}") return None def _save_to_cache(self, cache_key: str, result: Dict[str, Any]): """保存结果到 Redis,TTL 24h""" try: redis_client.setex(cache_key, 86400, json.dumps(result)) logging.info(f"Cache saved for key {cache_key[:8]}...") except Exception as e: logging.warning(f"Cache save failed: {e}") @retry( stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=10), retry=retry_if_exception_type((requests.exceptions.Timeout, requests.exceptions.ConnectionError)) ) def _call_openrouter(self, input_dict: Dict[str, Any], model_name: str) -> Dict[str, Any]: """调用 OpenRouter API,含重试""" payload = { "model": model_name, "messages": [ {"role": "system", "content": "你是一个专业的销售预测模型,只输出 JSON 格式结果,包含 forecast_result 和 confidence_score 字段。"}, {"role": "user", "content": f"根据销售数据 {input_dict['sales_data']} 和季节系数 {input_dict['season_factors']},预测未来 {input_dict['forecast_period']} 个月的销售额。"} ], "temperature": 0.0, # 关键!数学建模必须 deterministic "max_tokens": 512 } try: start_time = time.time() response = requests.post( self.base_url, headers=self.headers, json=payload, timeout=(5, 15) # connect=5s, read=15s ) response.raise_for_status() elapsed = time.time() - start_time logging.info(f"OpenRouter call succeeded in {elapsed:.2f}s with {model_name}") # 归一化响应 data = response.json() content = data["choices"][0]["message"]["content"] # 解析 content 中的 JSON(假设模型返回的是 JSON 字符串) try: result_json = json.loads(content) return { "result": result_json.get("forecast_result", "N/A"), "confidence": result_json.get("confidence_score", 0.0), "model_used": model_name } except json.JSONDecodeError: logging.error(f"Invalid JSON from model: {content[:100]}") raise ValueError("Model returned invalid JSON") except requests.exceptions.HTTPError as e: if response.status_code == 401: logging.error("OpenRouter auth failed, refreshing token...") # 此处应有 token 刷新逻辑,因 OpenRouter 使用静态 API Key,故省略 raise e elif response.status_code == 429: logging.warning("Rate limited, will retry...") raise e else: logging.error(f"HTTP error {response.status_code}: {response.text}") raise e def execute(self, input_dict: Dict[str, Any]) -> Dict[str, Any]: """主执行方法,含缓存、重试、降级""" cache_key = self._generate_cache_key(input_dict) cached_result = self._get_from_cache(cache_key) if cached_result: return {**cached_result, "cached": True} try: # 先用主模型 result = self._call_openrouter(input_dict, self.primary_model) except Exception as e: logging.warning(f"Primary model failed: {e}, falling back to {self.fallback_model}") try: result = self._call_openrouter(input_dict, self.fallback_model) except Exception as e2: logging.error(f"Both models failed: {e2}") # 最终降级:返回默认值 result = { "result": "N/A", "confidence": 0.0, "model_used": "fallback_rule_engine" } # 保存到缓存 self._save_to_cache(cache_key, result) return {**result, "cached": False} # Skill 主函数 def main(input_data: Dict[str, Any]) -> Dict[str, Any]: """WorkBuddy Skill 入口函数""" try: # 1. 输入校验(Pydantic) validated_input = MathModelInput(**input_data) # 2. 执行模型调用 client = ModelClient() raw_result = client.execute(validated_input.model_dump()) # 3. 输出适配:转换为 WorkBuddy 要求的格式 output = { "forecast_result": raw_result["result"], "confidence_score": raw_result["confidence"], "model_used": raw_result["model_used"], "cached": raw_result.get("cached", False) } # 4. 置信度低时添加警告 if raw_result["confidence"] < 0.6: output["warning"] = "预测置信度偏低,建议人工复核" logging.info(f"Skill executed successfully: {output}") return {"output": output} except ValidationError as e: # Pydantic 校验失败 errors = "; ".join([f"{err['loc'][0]}: {err['msg']}" for err in e.errors()]) logging.error(f"Input validation error: {errors}") return {"output": {"error": f"输入错误:{errors}"}} except Exception as e: logging.error(f"Unexpected error: {e}") return {"output": {"error": "系统内部错误,请稍后再试"}} # 注意:WorkBuddy 要求入口函数名为 main,且参数名为 input_data这段代码体现了 15 轮迭代的全部精华:从最基础的 HTTP 调用,到重试、缓存、降级、日志、错误分类处理。每一行都有其存在的理由,没有一行是“为了看起来专业”而写的装饰。
4.3 测试与发布流程:如何让业务方放心用
WorkBuddy 提供了完整的测试闭环:
本地测试:在 Skill 编辑页点击 “Test Locally”,输入 JSON 示例,实时看输出和日志。我为每个典型场景都写了测试用例:
- 正常场景:
{"sales_data": [100, 120, 110], "season_factors": [0.25, 0.25, 0.25, 0.25], "forecast_period": 3} - 边界场景:
{"sales_data": [-10, 120], ...}(应触发校验失败) - 故障模拟:临时把
OPENROUTER_API_KEY设为空,验证降级逻辑是否生效
- 正常场景:
灰度发布:发布时选择 “Release to specific users”,只对财务部 3 位同事开放。他们用一周时间在真实业务数据上测试,反馈了 2 个关键问题:一是模型对“Q4”缩写识别不准,二是当
forecast_period为 1 时,结果格式与其他情况不一致。这两个问题都在第14轮修复。监控告警:WorkBuddy 的 “Metrics” 页面提供 3 个核心指标:成功率(Success Rate)、平均延迟(Avg Latency)、错误分布(Error Breakdown)。我把成功率告警阈值设为 95%,当连续 5 分钟低于此值,自动邮件通知我。第11轮曾触发告警,原因是 OpenRouter 的某个模型 endpoint 返回了非标准 JSON,我通过 Error Breakdown 定位到具体 model name,临时将其从主用列表移除。
5. 常见问题与排查技巧实录:那些文档里不会写的坑
5.1 “Unexpected status 401 Unauthorized: authentication fails” —— 不是 Key 错了,是 Header 少了
这是前 5 轮最频繁的报错。我反复确认 Key 正确,甚至用 curl 手动测试都成功,但 Skill 里就是 401。最终发现:OpenRouter 的文档里写着 “All requests must includeAuthorization: Bearer <key>”,但没强调还必须包含HTTP-Referer和X-Title。缺少这两个 header,OpenRouter 会静默拒绝,返回 401。WorkBuddy 的日志里只显示 status code,不显示 request headers,排查时我用了curl -v对比,才抓到这个差异。解决方案已在代码中体现:self.headers字典里强制包含这两项。
5.2 “Your api key: ****” —— 日志里为什么只显示星号?
WorkBuddy 的日志系统对os.getenv()读取的 env var 做了自动脱敏,所有匹配.*key.*|.*secret.*|.*token.*的变量值,在日志里都会被替换为****。这很好,但导致一个问题:当 API Key 真的失效时,你无法从日志里确认是不是 Key 本身的问题。我的 workaround 是:在 Skill 启动时,打印len(api_key)和api_key[:4] + "***",这样既能确认 Key 被正确加载(长度非零),又不泄露全量。
5.3 “Connection timed out” —— 不是网络问题,是 DNS 解析慢
有一次,Skill 在 95% 的请求里都超时,但ping openrouter.ai是通的。用time curl -I https://openrouter.ai发现 DNS 解析占了 4 秒。WorkBuddy 的沙箱环境 DNS 配置较保守。解决方案:在ModelClient.__init__()里,用socket.gethostbyname("openrouter.ai")预解析一次,缓存 IP 地址,后续请求直接用 IP,绕过 DNS。实测将平均连接时间从 4.2 秒降到 0.08 秒。
5.4 “Model returned invalid JSON” —— 当模型“胡说八道”时怎么办?
LLM 的本质是概率模型,即使temperature=0.0,也无法 100% 保证输出格式。第7轮我遇到模型在content字段里返回了大段解释文字,而不是纯 JSON。我的应对策略是三级 fallback:
- 一级:用
json.loads()尝试解析,失败则进入二级; - 二级:用正则
r'\{.*?\}'提取第一个 JSON object 字符串,再解析,失败则进入三级; - 三级:返回
{"result": "N/A", "confidence": 0.0},并记录日志Model output malformed: {content[:200]}。
这个策略让 Skill 的可用性从 82% 提升到 99.7%。关键是:不要试图“修复”模型输出,而是优雅地承认它的不确定性,并给出确定性的 fallback。
5.5 “Skill execution timeout after 30 seconds” —— 为什么设置了 15s read timeout 还超时?
WorkBuddy 的 Skill 运行时有一个全局 timeout,默认 30 秒。我的requests.timeout=(5,15)是指连接 5 秒、读取 15 秒,但整个 Skill 进程还有启动开销、Pydantic 校验、Redis 操作等。第10轮我遇到一次,日志显示requests在 14.8 秒完成,但 Skill 还是超时了。根本原因是:WorkBuddy 的 30 秒 timer 从进程启动开始计时,不是从main()函数开始。解决方案是:在main()开头加logging.info(f"Process start at {time.time()}"),结尾加logging.info(f"Process end at {time.time()}"),对比时间差。最终发现是 Redis 连接初始化慢(首次连接需 TLS 握手),于是我改成懒加载:只在需要缓存时才初始化redis_client,避免冷启动耗时。
注意:WorkBuddy 的 Skill 每次调用都是全新进程,没有“连接池”概念。所以
redis.Redis()实例应该在execute()方法内创建,而不是作为全局变量——否则每次调用都会新建连接,快速耗尽 socket。
6. 实战经验总结:关于模型集成,我学到的最重要三件事
我在第20天的复盘笔记里,把这 15 轮迭代浓缩成三条血泪教训,它们比任何技术细节都重要:
第一,永远假设模型会撒谎,但不要假设它会恶意撒谎。LLM 的错误不是 bug,而是特性。它可能把“2024Q1”理解成“2024年第一季度”,也可能把“-”当成减号而非缺失值。对抗它的唯一方式不是写更复杂的 prompt,而是建立“输入-处理-输出”的全链路校验:输入层用 Pydantic 拦截非法数据,执行层用 timeout 和重试控制不确定性,输出层用 JSON Schema 验证结构。这三层校验像三道闸门,让错误在传播前就被截停。
第二,API Key 不是密码,而是服务契约的签名。把它硬编码、截图分享、写在 README 里,本质上是在透支你和 OpenRouter 之间的信任。WorkBuddy 的 Secret Management 是底线,但真正的安全在于:Key 的生命周期管理(定期轮换)、最小权限原则(为 math-skill 单独申请 Key,不复用 dev-key)、以及密钥泄露后的快速响应预案(一键禁用 + 通知 OpenRouter)。我第4轮就建立了 Key 轮换 checklist,现在每 30 天自动提醒我更新。
第三,“能用”和“敢用”之间,隔着 100 次失败的日志分析。业务方愿意把真实销售数据交给你跑模型,不是因为你 demo 时 100% 成功,而是因为你能在第 101 次失败时,30 秒内定位到是 OpenRouter 的gemini-2.0-flash模型返回了非标准字段。这要求你把日志当作第一生产力工具:每条日志必须带 trace_id(我用uuid.uuid4().hex[:6]生成),必须区分 INFO/WARNING/ERROR 级别,必须记录关键决策点(如“降级到 claude-3-haiku”)。当第15轮上线后,我打开 Metrics 页面,看到错误率曲线从最初的 18% 一路压到 0.3%,那一刻我知道,不是模型变好了,是我终于读懂了它每一次失败的语言。
这个 Skill 现在每天处理 327 次请求,平均延迟 2.1 秒,成功率 99.84%。它没有改变任何模型的能力,但它让模型的能力,真正变成了业务方可以信赖的生产力。如果你也在模型集成的路上磕磕绊绊,希望这份实录里那些具体的参数、真实的报错、笨拙的 workaround,能帮你少踩几个坑。毕竟,所有“丝滑”的背后,都藏着一堆被删掉的调试 print。