1. 先搞清楚这套组合到底解决了什么问题
1.1 Step 3.5 Flash 与 OpenRouter 分别是什么角色
Step 3.5 Flash 是一个主打"快"和"便宜"的轻量级对话模型,定位非常明确:它不跟你比谁写长篇小说更细腻,而是比谁在毫秒级响应、批量处理、高并发场景下更划算。你可以把它想成一辆城市通勤小车——不拉货、不越野,但从 A 点到 B 点又快又省油。真正让它在实际项目里好用的,是它对 OpenAI 风格接口的兼容性,这意味着你现有的调用代码基本不用改结构,换个 base_url 和 model 名就能跑。
OpenRouter 则是一个模型聚合网关。它做的事情很朴素:把各家模型统一到一套 HTTP 接口后面,你用同一个 API Key、同一种请求体格式,就能切换不同厂商的模型。对我们这些要在一堆模型之间做对比测试的人来说,这个东西的价值不在于"多",而在于"统一"——不用为每个厂商注册账号、充值、读一遍各自的鉴权文档、写一套各自的 SDK 封装。你只要维护一个 Key,一套重试逻辑,一份监控代码。
把这两个东西放在一起,得到的就是一个非常适合练手和做原型的组合:模型侧成本低、延迟低,网关侧接入成本低、切换成本低。标题里说的"免费",指的就是 OpenRouter 上那批带:free后缀的模型条目,Step 3.5 Flash 的免费版本通常就在其中。这个免费额度不是无限量的,它有明确的速率限制和每日上限,但对于学习、验证想法、跑小规模数据处理来说,够用。
我见过太多人卡在第一步——不是卡在不会写代码,而是卡在"我到底该用哪个模型、从哪里拿到 Key、免费额度够不够我折腾"。这篇东西就是把这几个问题一次说透。
1.2 免费额度这件事,账要提前算明白
先把预期摆正:免费 ≠ 无限。OpenRouter 对:free模型条目普遍设有两层限制,一层是每分钟请求数,一层是每日请求数。前者防止你瞬时打爆,后者防止你挂个脚本跑一整晚。具体数字会随平台策略调整,我不在这里写死,但你可以按经验值估算:免费档位通常在个位数到几十次每分钟之间,每日总量在几十到几百次之间浮动,账号里充过余额的账号额度会明显更宽松。
这意味着什么?意味着你不能拿免费额度去做生产环境的实时服务。一个用户点一下按钮就发一次请求的产品,日活上百就直接撞墙。但它完全够你做这些事:跑一遍模型能力评测、验证 prompt 效果、给内部工具做个 demo、批量处理几百条文本做清洗和分类、给学生演示什么是 API 调用。
我给你一个粗略的估算方法。假设你每日额度是 N 次请求,每次请求平均消耗的输入 token 是 500、输出是 300,那么一天能处理的数据量大约是 N 条。如果你要处理 5000 条文本,N 只有 200,那就意味着要分 25 天跑,显然不现实——这时候要么换付费档,要么换成本地小模型跑批量。先算账再动手,这是我踩过坑之后养成的习惯,比写到一半发现额度用完再回头改架构要省事得多。
1.3 谁适合用这套方案,谁应该绕道
适合的人:想快速验证一个 AI 功能能不能落地的前端/后端工程师;需要给学生或团队做演示的技术负责人;手上有一批文本想先跑个分类看看效果的数据分析人员;以及纯粹想搞明白"大模型 API 到底怎么调"的初学者。
不适合的人:要做高并发线上服务、要求 SLA 保障、对数据不出境有硬性要求、需要模型输出绝对稳定的场景。免费额度本质上是"试用资源",它的可用性、延迟、限流策略都不在你的控制范围内,把它当生产依赖是在给自己埋雷。
还有一个容易被忽略的点::free条目的响应质量和付费条目可能不完全一致。有些平台会对免费档做降级处理,比如更激进的上下文截断、更短的输出上限,或者路由到负载更低的节点。所以如果你用免费版测出来效果不错,切到付费版时最好再回归测一遍,别默认两者等价。
2. 动手前的准备:账号、密钥与环境
2.1 注册、建 Key 的实际操作路径
整个流程比大多数人想象的短。打开 OpenRouter 的站点,用邮箱或第三方账号登录,进入控制台的 Keys 页面,点创建新密钥,起个能认出来的名字(比如local-dev-test),生成后立刻复制。这里有个细节:密钥明文只显示一次,页面一刷新就看不到了,只能重新生成。我见过有人手快点了刷新,然后对着满屏的sk-or-v1-xxxx前缀发呆,最后只能删掉重建。
创建时如果有"额度上限"的选项,建议填一个小数值,比如 5 美元。原因很实在:一旦 Key 泄露,攻击者能烧的钱是有天花板的。免费模型虽然不扣钱,但你的账号如果绑了支付方式,别人可以拿你的 Key 去调付费模型。这个上限就是你的止损线。
拿到 Key 之后,去模型列表页搜一下step,找到 Step 3.5 Flash 对应的条目,页面上会直接给出模型标识符(形如厂商名/模型名,免费版本通常带:free后缀)和一段可直接复制的调用示例。把这个标识符原样复制下来,不要自己拼,厂商命名里经常有连字符、斜杠、版本号的坑,手打十个有八个错。
2.2 密钥管理的三条硬规矩
第一条,绝不写死在源码里。不管你多赶时间,多临时,都不要把sk-or-...直接粘进.py文件。原因不是洁癖,是现实:这类文件经常被顺手推到公开仓库、打包进前端产物、或者截图发到群里。用环境变量,一行export OPENROUTER_API_KEY="你的key"或者写进.env文件然后.gitignore掉,成本几乎为零。
第二条,不同用途用不同的 Key。本地开发一个、CI 流水线一个、给同事试用一个。这样出问题时你能精准吊销某一个,而不至于把自己正在跑的测试一起干掉。这个做法在团队里尤其重要,因为"某个 Key 是谁在用"这个问题,通常在你需要紧急吊销的那一刻才被发现答不上来。
第三条,本地开发也别裸奔。即便只是自己电脑上的脚本,也建议走.env+python-dotenv这套组合。听起来多此一举,但当你半年后回头看自己写的 demo,能一眼看懂哪些是配置、哪些是逻辑,那种清爽感值这几分钟的麻烦。
2.3 环境与依赖:最少要装什么
Python 侧最省事的路线是用官方openaiSDK,把它指向 OpenRouter 的兼容端点。这不是投机取巧,而是因为 OpenRouter 的接口设计本身就对齐了这套协议,用现成 SDK 能省掉你处理 SSE 解析、重试、类型标注的一大堆体力活。
pip install openai python-dotenvNode.js 侧同理,npm i openai dotenv就够。如果你就是想用最原始的curl或requests手搓,也完全可以,只是流式响应那块的解析要自己写,容易在 chunk 边界上翻车。我个人的建议是:先用 SDK 把链路跑通,理解清楚请求长什么样、响应长什么样,再考虑要不要手搓。顺序反了容易在细节里迷路。
环境检查这一步别省。跑之前确认三件事:Python 版本不要太老(3.9 以上比较稳)、网络能正常访问目标域名、环境变量确实被读到了。第三点听起来傻,但我见过太多次"代码没问题但 Key 是空字符串"导致的 401,排查半小时最后发现是.env文件和脚本不在同一个工作目录。
3. 第一次调用:从 curl 到可复用的封装
3.1 用 curl 做最小可验证请求
在写任何封装代码之前,先用一条命令确认"这条路是通的"。这一步的价值在于隔离变量:如果 curl 失败,问题在网络或 Key;如果 curl 成功而代码失败,问题在代码。省掉大量来回猜的时间。
export OPENROUTER_API_KEY="sk-or-v1-你的密钥" curl https://openrouter.ai/api/v1/chat/completions \ -H "Authorization: Bearer $OPENROUTER_API_KEY" \ -H "Content-Type: application/json" \ -H "HTTP-Referer: https://your-site.example" \ -H "X-Title: first-smoke-test" \ -d '{ "model": "stepfun/step-3.5-flash:free", "messages": [ {"role": "user", "content": "用三句话解释什么是向量检索"} ] }'响应体是标准的 chat completion 结构,你要关心的核心字段是choices[0].message.content,另外usage里的prompt_tokens、completion_tokens、total_tokens一定要看一眼——这是你后续估算额度消耗的唯一依据。
HTTP-Referer和X-Title这两个头是可选的,平台用它们做调用来源统计和排行展示。有些文档说必须带,实际上不带也能通,但带上没坏处,尤其当你以后想在这个平台上管理多个项目时,这两个头能帮你区分流量来源。
3.2 Python 封装:一个够用很久的调用函数
封装的目标不是"写得漂亮",是"出错时能定位"。下面这个版本我一直在用,核心是三件事:单例客户端、超时控制、返回值里带上 usage。
import os from openai import OpenAI from dotenv import load_dotenv load_dotenv() MODEL_ID = "stepfun/step-3.5-flash:free" client = OpenAI( base_url="https://openrouter.ai/api/v1", api_key=os.environ["OPENROUTER_API_KEY"], timeout=60.0, max_retries=0, # 重试逻辑自己控,见第 5 节 ) def chat(prompt: str, system: str | None = None, temperature: float = 0.7, max_tokens: int = 1024) -> dict: messages = [] if system: messages.append({"role": "system", "content": system}) messages.append({"role": "user", "content": prompt}) resp = client.chat.completions.create( model=MODEL_ID, messages=messages, temperature=temperature, max_tokens=max_tokens, ) return { "text": resp.choices[0].message.content, "finish_reason": resp.choices[0].finish_reason, "usage": { "in": resp.usage.prompt_tokens, "out": resp.usage.completion_tokens, }, } if __name__ == "__main__": r = chat("把这句话改写成更正式的表达:这破功能又崩了") print(r["text"]) print(r["usage"])几个设计取舍值得说清楚。max_retries=0是故意的,SDK 自带的重试是固定次数的,遇到 429 时它不会考虑平台返回的Retry-After,容易在限流场景下加重拥堵。重试这件事自己写更可控。timeout=60是因为 Flash 类模型虽然快,但排队和冷启动不可控,给个宽松值比频繁超时好。返回 dict 而不是裸字符串,是为了强制自己每次都看finish_reason——length说明被max_tokens截断了,这时候你拿到的文本是半截的,直接塞进下游逻辑会出问题。
3.3 参数怎么设:三个最容易设错的旋钮
temperature。做分类、抽取、格式转换这类需要稳定输出的任务,压到 0 到 0.3;做文案、头脑风暴、多样化改写,拉到 0.7 到 1.0。我见过有人做结构化抽取时用 0.9,然后抱怨"模型今天抽出来的字段名和昨天不一样"——这不是模型的问题,是你让它自由发挥了。
max_tokens。这个值直接决定你单次请求的成本和延迟上限。设太大,模型可能在结尾啰嗦一堆废话;设太小,输出被硬截断。经验做法是:先用一个宽松值跑几条,看实际 completion_tokens 的分布,取 95 分位再上浮 20%。比如你发现大多数输出在 400 到 600 token,那就设 720 左右。
stream。默认关闭。但只要你做的是"用户盯着屏幕等结果"的界面,就该开。原因在第 4 节展开。
提示:
max_tokens只限制输出,不限制输入。如果你的 prompt 本身很长,或者塞了几万字的上下文,那部分消耗照样计入prompt_tokens,不会因为max_tokens小而减少。
4. 流式输出、多轮对话与结构化返回
4.1 流式响应到底快在哪
很多人以为流式让模型生成变快了,其实不是。模型侧的总耗时基本不变,变的是首字延迟(time to first token)。非流式请求要等整段内容生成完才能返回,你在界面上看到的就是一个转圈几十秒;流式请求是每生成一小段就推一批回来,用户几百毫秒内就能看到文字开始滚动。同样 20 秒的总耗时,一个体验是"卡住了",另一个体验是"它正在思考",差别巨大。
代码上很简单,把stream=True打开,然后迭代返回的 chunk:
def chat_stream(prompt: str, system: str | None = None): messages = [] if system: messages.append({"role": "system", "content": system}) messages.append({"role": "user", "content": prompt}) stream = client.chat.completions.create( model=MODEL_ID, messages=messages, stream=True, ) buf = [] for chunk in stream: if not chunk.choices: continue delta = chunk.choices[0].delta piece = delta.content if piece: buf.append(piece) print(piece, end="", flush=True) print() return "".join(buf)有两个坑必须提。第一,chunk 里不一定总有choices,某些情况下(比如流末尾的 usage 汇总)会返回空数组,不做判空直接取[0]会崩。第二,delta.content可能是None,尤其第一个 chunk 只带 role 信息,这时候要做保护。这两个坑我在不同项目里各踩过一次,报错信息都是IndexError或TypeError,看起来和"流式"毫无关系,排查起来很浪费时间。
4.2 多轮对话的上下文是自己维护的
这里有个认知偏差需要纠正:模型是无状态的。它不记得你上一句说了什么。所谓"多轮对话",是你每次把完整的历史消息数组重新发一遍。SDK 不会帮你存,网关也不会。
history = [{"role": "system", "content": "你是一个简洁的技术助手,回答不超过 100 字。"}] def say(user_input: str) -> str: history.append({"role": "user", "content": user_input}) resp = client.chat.completions.create( model=MODEL_ID, messages=history, max_tokens=512, ) reply = resp.choices[0].message.content history.append({"role": "assistant", "content": reply}) return reply这段代码能跑,但不能直接上生产,因为history会无限增长。聊到第 30 轮,你每次请求都在为前 29 轮付 token 钱,延迟也会越来越长,最后撞上模型的上下文窗口上限直接报错。
实际的解法是滑动窗口加摘要。简单粗暴的版本:只保留最近 N 轮(比如 10 轮),更早的直接丢弃。讲究一点的版本:当历史超过阈值时,让模型把早期对话压缩成一段摘要,用摘要替换掉原始消息。我一般用前者,因为在客服、助手类场景里,用户提到的事情八成在前 10 轮内;后端做法适合长文档分析这类需要长记忆的任务。
4.3 要稳定拿到 JSON,靠的是 prompt 加校验,不是祈祷
让模型输出 JSON 是做工程集成时最常干的事,也是最容易翻车的事。三条经验:
第一,在 prompt 里给出确切的 schema,并给一个例子。光说"返回 JSON"不够,要说清楚有哪些字段、每个字段什么类型、哪些可为 null。给一个输入输出配对示例,效果比写三行描述还好。
第二,用response_format如果能用就用。支持的情况下传{"type": "json_object"},能显著降低输出里混入解释文字的概率。但注意,开了这个参数后 prompt 里必须出现 "json" 字样,否则部分实现会报错。
第三,永远做二次校验。拿到字符串后json.loads,失败就重试一次,重试还失败就降级走正则兜底或者直接返回错误。代码里写一个safe_json_parse函数,比在业务逻辑里到处try/except干净得多。
import json def safe_json_parse(text: str, retries: int = 1): for _ in range(retries + 1): try: return json.loads(text) except json.JSONDecodeError: # 去掉可能的 ```json 包裹 cleaned = text.strip().removeprefix("```json").removeprefix("```").removesuffix("```").strip() try: return json.loads(cleaned) except json.JSONDecodeError: continue return NoneFlash 类小模型在严格格式化上的稳定性通常不如大模型,所以这套兜底不是可选项,是必需品。
5. 踩坑实录:免费 API 的高频故障与处理
5.1 常见报错对照与处置
我把实际遇到过的错误整理成一张表,遇到问题先在这里对一遍,能省下不少搜论坛的时间。
| 状态码 / 现象 | 典型原因 | 处置办法 |
|---|---|---|
| 401 Unauthorized | Key 为空、拼错、被吊销 | 打印 Key 前 8 位确认非空,去控制台核对状态 |
| 402 Payment Required | 账号额度耗尽或该模型非免费 | 换:free条目,或检查账号余额 |
| 403 Forbidden | 该模型对当前账号不可用 | 换模型,或检查是否需要接受条款 |
| 404 Not Found | 模型标识符写错,或接口路径错 | 从模型页原样复制 slug |
| 429 Too Many Requests | 触发每分钟或每日限流 | 指数退避重试,见 5.2 |
| 空响应 / content 为 None | 输出被安全策略拦截,或超时 | 检查 finish_reason,加兜底文案 |
| finish_reason = length | 被 max_tokens 截断 | 调大上限或精简 prompt |
有一类问题不在表里但很常见:调用成功但内容质量突然变差。这种情况八成是模型路由到了不同的后端节点,或者免费档在高峰期被降级。我的做法是在 prompt 里加一句强约束(比如"只输出 JSON,不要任何解释"),并且在下游加一层格式校验,让质量波动不至于直接传导到用户界面。
5.2 限流与重试:写对了能省一半麻烦
429 是免费额度下最常撞的墙。错误的处理方式是time.sleep(1)然后立刻重试——如果限流窗口是 60 秒,你这一秒等完再打还是 429,反复循环反而把窗口填满。
正确做法是指数退避加随机抖动:
import random, time def call_with_retry(fn, max_attempts: int = 5): for attempt in range(max_attempts): try: return fn() except Exception as e: msg = str(e) is_rate_limit = "429" in msg or "rate" in msg.lower() if not is_rate_limit or attempt == max_attempts - 1: raise base = 2 ** attempt # 1, 2, 4, 8 秒 jitter = random.uniform(0, 0.5) time.sleep(base + jitter)抖动的作用是防止多个并发任务同时醒来、同时重试,形成新的尖峰。这个技巧在批量任务里尤其重要——你并行跑 10 个请求全被限流,如果没有抖动,它们会在同一毫秒一起重试,然后一起再被限流。
另外,如果响应头里带了Retry-After,优先按它来,别用自己算的退避时间。那是服务端明确告诉你的"多久之后再来",比猜准得多。
5.3 成本与用量的日常监控
即便用免费额度,也建议把 usage 记下来。理由有两个:一是免费额度会变(平台调整策略、账号状态变化),有历史数据你能第一时间发现异常;二是你在免费档验证完要切付费时,这份数据就是你估预算的依据。
最简单的做法是每次调用后往一个 JSONL 文件里追加一行:
import json, time, pathlib LOG = pathlib.Path("usage.jsonl") def log_usage(model: str, usage: dict, tag: str = ""): with LOG.open("a", encoding="utf-8") as f: f.write(json.dumps({ "ts": time.time(), "model": model, "tag": tag, **usage, }, ensure_ascii=False) + "\n")跑一周之后,用 pandas 读进来,按tag分组算一下平均 token 消耗,你就知道自己每条业务请求大概烧多少资源,也就能反推出免费额度撑得住多少量。这个动作花不了十分钟,但能避免"上线第二天额度就空了"的尴尬。
注意:日志里不要记录完整 prompt 和响应内容,尤其当输入里可能有用户隐私信息时。只记 token 数、模型名、时间戳、业务标签,够用了。
6. 三个可以直接抄过去的落地场景
6.1 批量文本清洗与分类脚本
这是免费额度最匹配的用法:离线跑、不急、可以慢慢排队。典型任务是给一批用户反馈打标签、给文章分主题、把散乱的地址信息结构化。关键在于控制并发和断点续跑。
并发别开太大,免费额度下 3 到 5 个线程就够,再高纯属给自己制造 429。断点续跑更重要:处理到第 800 条时额度用完,你要能从第 801 条接着跑,而不是从头再来。实现方式是每条处理完立刻把结果写进输出文件,启动时先读已有结果算出已处理集合,跳过。
def process_batch(items, out_path="result.jsonl", workers=4): done = set() if os.path.exists(out_path): with open(out_path, encoding="utf-8") as f: for line in f: done.add(json.loads(line)["id"]) todo = [x for x in items if x["id"] not in done] with open(out_path, "a", encoding="utf-8") as f: for item in todo: r = call_with_retry(lambda: chat(build_prompt(item))) f.write(json.dumps({"id": item["id"], **r}, ensure_ascii=False) + "\n") f.flush()flush()那行别删。少了它,程序被中断时缓冲区里的数据可能丢失,你上一次的进度就白跑了。
6.2 内部工具的轻量问答入口
给团队做一个"查内部文档"的小工具,是最能体现 Flash 类模型价值的地方。这种场景对回答深度要求不高,对响应速度和成本敏感。架构上就是检索加生成:先把文档切片、建向量索引,用户提问时召回 top-k 片段拼进 prompt。
这里有个容易被忽略的成本点:召回片段塞进 prompt 会显著拉高prompt_tokens。如果你每次召 5 段、每段 500 字,光输入就三四千 token。免费额度下这会让你每天能处理的请求数骤降。我的做法是把召回数压到 2 到 3 段,并且在切片时控制每片长度,宁可多切几片保证精度,也不要单片塞太长。
另一个实操细节:在 system prompt 里明确告诉模型"只根据给定资料回答,资料里没有就说不知道"。不加这句,它会很自然地开始编。小模型的幻觉倾向通常比大模型更明显,这条约束能挡掉相当一部分问题。
6.3 用降级路由把成本压到最低
真实项目里比较聪明的做法,是做一个模型路由层:常规请求走 Flash 类小模型,遇到复杂任务或小模型明确表示"不确定"时,升级到更强的模型。这套逻辑在 OpenRouter 这种聚合网关上实现起来特别顺,因为切换模型只是改一个字符串。
def route_chat(prompt: str, complex_hint: bool = False) -> str: if complex_hint or len(prompt) > 2000: model = BIG_MODEL_ID else: model = MODEL_ID resp = client.chat.completions.create( model=model, messages=[{"role": "user", "content": prompt}], ) return resp.choices[0].message.content判断"要不要升级"的规则可以从简单开始:输入长度超阈值、任务类型是代码生成或长文推理、用户显式要求高质量。跑一段时间后看日志,你会发现自己能算出一个明确的升级比例,比如 15% 的请求需要走大模型。这 15% 就是你的成本主项,也是后续优化的发力点。
提示:路由层的模型 ID 一定做成配置项,别写死在业务代码里。模型上下架、版本更新、价格调整都很频繁,硬编码的后果是某天早上服务突然全挂,而你还在翻业务逻辑。
7. 我在这套组合上攒下的几条实战经验
把项目从"能跑"推到"敢用",中间隔着的往往不是技术难点,是一些琐碎的判断。分享几条我个人在这套组合上反复验证过的做法。
先把 prompt 在 playground 里调好再写代码。很多人习惯边写代码边调 prompt,改一次跑一次,一来一回好几分钟。平台通常提供在线试玩界面,在那里快速迭代措辞,定稿了再粘进代码,效率差好几倍。而且 playground 里能直接看到 token 消耗,调 prompt 时顺便就把成本摸清了。
给每个请求打上业务标签。我在chat()函数里加了个tag参数,写 "classify"、"extract"、"qa" 这种,日志里按标签聚合之后,哪个功能最烧 token 一目了然。没有这个标签,你只能看到总量,没法定位优化对象。
免费额度当"开发环境",别当"生产环境"。这个心态上的区分很重要。开发阶段额度用完就等明天,无所谓;生产环境额度用完就是事故。所以架构设计时就要把"模型不可用"当成必然发生的分支来处理:降级到本地小模型、返回缓存结果、给用户一个明确的提示,都行,但不能让请求直接抛异常到界面上。
别迷信"最新最强",按任务选模型。分类、抽取、格式转换这类任务,Flash 级别的模型往往和大模型打平,成本却差一个量级。我做过一次对比:同一个 500 条的抽取任务,小模型准确率 91%,大模型 94%,但那 3 个百分点要付出十几倍成本,放在这个业务里完全不值。先跑基准测试再决定,别凭感觉。
注意上下文窗口这个隐形上限。小模型的上下文窗口通常比旗舰模型小。你如果拿它做长文档问答,很可能在输入阶段就被截断或者报错,而且错误信息未必直白。做法是在拼接 prompt 之前先做 token 估算,超出预算就先做摘要压缩或者减少召回片段,别把问题丢给服务端。
最后,给自己留一条不用网络的退路。不管这套 API 多稳,总会有网络抖动、平台维护、账号异常的时候。本地跑一个小参数量的模型作为兜底,哪怕质量差一截,也能保证你的工具在最糟的情况下还能出结果。这个成本不高,但在关键时刻能救命。