别只打印 content:蓝耘元生代 MaaS 流式输出、思维链与 Token 陷阱实战
别只打印 content:蓝耘元生代 MaaS 流式输出、思维链与 Token 陷阱实战
主测模型:
deepseek-v4-flash· Base URL:https://maas-api.lanyun.net/v1
0. 先说结论
很多人接完蓝耘 MaaS,代码长这样:
print(resp.choices[0].message.content)或者流式里只拼delta.content。能聊,但上线后容易踩三类坑:
| 坑 | 现象 | 根因 |
|---|---|---|
| 体感假死 | 用户干等几秒才突然出字 | 用了非流式,或流式没flush/ 被中间层缓冲 |
| 答案是空的 | content == "",finish_reason=length | max_tokens太小,额度被思维链吃光 |
| 账单对不上 | 感觉「没说几句」,Token 却不少 | 忽略了reasoning_tokens/ 缓存字段 |
一句话口诀:
UI 看流式,答案看 content,成本看 usage,截断先查 reasoning。
蓝耘这边值得写进工程笔记的,不只是「兼容 OpenAI」——而是:
- 统一网关:换 DeepSeek / Qwen 只改
model; - usage 字段透明:
reasoning_tokens、cached_tokens能直接读; - 流式里可能带
reasoning_content:产品层可以做成「思考中」折叠区。
1. 场景:我不是在写 Demo,是在给聊天框收尾
需求很具体:
- 终端 / Web 聊天框要「字在蹦」,不能整段弹出;
- 推理模型如果先「想」再答,前端要能区分思考区和正文;
max_tokens、超时、空回复要有可解释的排障路径;- 同一套 client,以后可能从 DeepSeek 切到 Qwen,不能推倒重来。
技术选型:
SDK : openai (Chat Completions) 平台 : 蓝耘元生代 MaaS base_url : https://maas-api.lanyun.net/v1 model : deepseek-v4-flash(当天列表可见;以 models.list 为准)旧文档里的
DeepSeek-V3/DeepSeek-R1可能已 404。接入前务必client.models.list()。
2. 非流式 vs 流式:同一句话,体感完全不同
提示词固定为:
用三句话说明什么是统一网关,要通俗。
2.1 本机单次观测(非正式压测)
| 模式 | 指标 | 数值 |
|---|---|---|
| 流式 | 约等于首包可见时间(TTFT,本机粗测) | 1.402 s |
| 流式 | 整段结束 | 4.669 s |
| 流式 | chunk 数 | 56 |
| 流式 | reasoning 字符量 / content 字符量 | 257 / 168 |
| 非流式 | 整段返回 | 3.727 s |
| 非流式 | usage | prompt 15 · completion 176 · total 191 ·reasoning_tokens 94 |
怎么读这些数:
- 非流式总耗时有时更短,但用户从发出请求到「看见第一个字」往往更久——因为要等整包。
- 流式 TTFT ≈ 1.4s,意味着大约 1.4 秒后终端/前端就可以开始动。
- 流式总时长 4.7s > 非流式 3.7s,在这次样本里成立;不要把单次对比写成平台结论,网络与是否先吐 reasoning 都会影响。
- 真正该写进产品文档的是:流式让等待可感知。
2.2 最小流式代码(生产向)
importosfromopenaiimportOpenAI client=OpenAI(api_key=os.getenv("LANYUN_API_KEY","sk-xxxx"),base_url="https://maas-api.lanyun.net/v1",)stream=client.chat.completions.create(model="deepseek-v4-flash",messages=[{"role":"user","content":"用三句话说明什么是统一网关,要通俗。"}],stream=True,max_tokens=512,# 见第 4 节:别设太小)reasoning_buf=[]content_buf=[]print("=== 思考 / 正文开始 ===")forchunkinstream:ifnotchunk.choices:continuedelta=chunk.choices[0].delta# 思维链:有就收,没有也不崩reasoning=getattr(delta,"reasoning_content",None)ifreasoning:reasoning_buf.append(reasoning)print(reasoning,end="",flush=True)# 前端可画到「思考中」content=getattr(delta,"content",None)ifcontent:content_buf.append(content)print(content,end="",flush=True)# 主气泡print("\n=== 结束 ===")print("reasoning_chars:",sum(len(x)forxinreasoning_buf))print("content_chars:",sum(len(x)forxincontent_buf))三个工程细节:
getattr(..., None):不是所有模型、每一轮都有reasoning_content;flush=True:否则管道/IDE 终端可能攒着不刷;- 先判
chunk.choices:部分 chunk 可能是空 choices(视 SDK/网关实现)。
3. 思维链不是彩蛋,是会进账单的「隐形输出」
非流式同一次调用,usage 长这样(结构为准,数值随请求变):
CompletionUsage( prompt_tokens=15, completion_tokens=176, total_tokens=191, completion_tokens_details=CompletionTokensDetails( reasoning_tokens=94, ... ), prompt_tokens_details=PromptTokensDetails( cached_tokens=0, ... ) )同时 message 上也能读到 reasoning:
msg=resp.choices[0].message content=msg.content reasoning=getattr(msg,"reasoning_content",None)我这次非流式返回的正文大意是:
统一网关像所有请求的总入口……负责鉴权、限流、转发……后端服务少管杂事……
而reasoning_content开头则是模型在「拆题」:三句话怎么组织、用什么比喻——用户不一定要看见,但平台可能已经计了reasoning_tokens。
3.1 产品怎么展示
| 区域 | 字段 | 建议 |
|---|---|---|
| 折叠「思考中」 | reasoning_content | 默认可收起;调试模式展开 |
| 主回答 | content | 永远是用户默认看到的 |
| 成本角标 | usage | 内网看板显示 reasoning / cached |
3.2 和蓝耘的关系
很多聚合 API 只给你最终字符串。
蓝耘这条 OpenAI 兼容链路上,usage 把 reasoning / cache 拆开了——你后面做:
- 按模型对比「有效信息密度」;
- Agent 多轮里盯
cached_tokens; - 发现「空 content 但在扣费」
才有数据抓手。这不是营销话术,是对接过才体会到的可观测性。
4. 今天最值钱的坑:max_tokens太小 → content 变空
我故意做了个「看起来合理」的设置:
resp=client.chat.completions.create(model="deepseek-v4-flash",messages=[{"role":"user","content":"解释智能路由,尽量详细。"}],max_tokens=16,# entice: 省钱stream=False,)print(repr(resp.choices[0].message.content))print(resp.choices[0].finish_reason)print(resp.usage)实跑结果:
content : '' # 空字符串! finish_reason : length usage : prompt=11, completion=17, total=28 reasoning_tokens: 16翻译成人话:
- 模型先写思维链;
max_tokens=16几乎全给了 reasoning;- 正文还没开始,长度上限到了;
- 你看到的是「调用成功但没话」,最像前端 bug,其实是参数问题。
4.1 排障清单(建议贴团队 Wiki)
当content为空时,按顺序查:
finish_reason是否为length?usage.completion_tokens_details.reasoning_tokens是否接近max_tokens?- 非流式 message / 流式过程中是否其实有
reasoning_content? - 把
max_tokens提到 256~1024 再试。
4.2 推荐默认值(实战向,非官方 SLA)
| 场景 | max_tokens 起点 | 说明 |
|---|---|---|
| 短答 / 分类 | 128~256 | 仍要给 reasoning 留余量 |
| 普通聊天 | 512~1024 | 更稳 |
| 长文 / 代码 | 2048+ | 同时看模型输出上限 |
| 只想要正文、少思考 | 换更「直给」的模型或看控制台是否支持关 thinking | 以平台能力为准 |
省钱优先砍的是无用的 system 与历史,不是一上来把 max_tokens 砍到两位数。
5. 统一网关:同一 client,换一行 model
排障文里强调过 404 模型名;这里补工程价值——多模型切换成本。
client=OpenAI(api_key=os.getenv("LANYUN_API_KEY","sk-xxxx"),base_url="https://maas-api.lanyun.net/v1",)defask(model:str,text:str)->str:resp=client.chat.completions.create(model=model,messages=[{"role":"user","content":text}],max_tokens=64,)returnresp.choices[0].message.contentor""print(ask("deepseek-v4-flash","只回复四个字:切换成功"))print(ask("qwen3.6-flash","只回复四个字:切换成功"))本机验证:qwen3.6-flash返回了「切换成功」(具体 usage 会因模型是否默认开启思考而差很多,我这次 Qwen 侧reasoning_tokens偏高,说明不同模型的「思考税」不一样——更要把 usage 打进日志)。
对业务的含义:
- 路由层可以按任务选模型:闲聊用 flash,重推理用 pro / 别的旗舰;
- 灰度发布只改配置中心的
model字符串; - 监控按
model维度拆 latency 与 token。
这正是蓝耘一个 Key + 一套 base_url + 模型广场的接入形态带来的结构优势:你学的是一套 OpenAI 方言,不是五套厂商方言。
6. 给聊天框的参考状态机
把流式事件映射成前端状态,比「直接 append 字符串」稳:
idle └─ user_send └─ connecting └─ streaming_reasoning ← delta.reasoning_content └─ streaming_content ← delta.content └─ finished ← 循环结束 └─ error ← 4xx/5xx/超时伪代码:
state="connecting"forchunkinstream:delta=chunk.choices[0].deltaifchunk.choiceselseNoneifnotdelta:continueifgetattr(delta,"reasoning_content",None):state="streaming_reasoning"ui.append_think(delta.reasoning_content)ifgetattr(delta,"content",None):state="streaming_content"ui.append_answer(delta.content)state="finished"# 若 answer 为空且 finish_reason==length → 提示增大 max_tokens可选增强:
- 心跳:超过 N 秒无 chunk → 显示「仍在生成」;
- 取消:关掉 HTTP 流,避免用户连点;
- 审计:把
model、usage、finish_reason写入请求日志(不要记完整 Key)。
7. cURL 速测流式(不写 Python 也能验)
exportLY_KEY="sk-xxxx"exportLY_BASE="https://maas-api.lanyun.net/v1"exportLY_MODEL="deepseek-v4-flash"curl-N"$LY_BASE/chat/completions"\-H"Authorization: Bearer$LY_KEY"\-H"Content-Type: application/json"\-d"{\"model\":\"$LY_MODEL\",\"stream\": true,\"max_tokens\": 256,\"messages\": [{\"role\":\"user\",\"content\":\"用一句话介绍蓝耘 MaaS 统一网关\"}] }"-N关闭缓冲。若这里已经一段段刷 SSE,而浏览器里是整包,问题多半在Nginx/网关/前端 fetch 缓冲,不在蓝耘模型本身。
8. 检查清单:流式上线前 10 项
协议与鉴权
base_url以/v1结尾,路径不要叠成/v1/v1- Key 仅环境变量;402 先查余额,再查代码
model来自models.list/ 控制台,不抄过期博客
流式正确性
stream=True,消费完整迭代器- 同时处理
reasoning_content与content - 终端
flush/ 浏览器禁用无意义的 proxy 缓冲
Token 与截断
max_tokens给 reasoning 留余量- 空 content 时打印
finish_reason+reasoning_tokens - 日志记录 usage 全字段
多模型
- 抽象
ask(model, messages),业务不写死厂商 SDK
9. 和前几篇怎么分工(方便你系列投稿)
| 篇 | 解决的问题 |
|---|---|
| 上手 OpenAI SDK | 注册、Key、第一次非流式 |
| 404 → 402 排障 | 模型下架、余额、list models |
| Claude Code 选型 | 延迟尾部、缓存、工具调用 |
| 本篇 | 流式体感、思维链字段、max_tokens 陷阱、统一网关切换 |
系列感比单篇堆功能完整——审核也能看出不是同一篇换标题。
10. 结尾
流式不是把stream=False改成True那么简单。
在蓝耘元生代 MaaS 上,我更想强调三层:
- 传输层:SSE/流式让首包可见,聊天框才像活人;
- 语义层:
reasoning_content与content分离,产品才能做「思考 / 回答」; - 计量层:
reasoning_tokens、cached_tokens让成本可解释——尤其是max_tokens把正文挤没的时候。
平台侧,OpenAI 兼容 + 统一网关 + 可观察的 usage,让这些工程问题可以在一套代码里闭环。
你要做的是:别只print(content),把思维链、截断原因和 Token 账本一起设计进系统。
本地可对照脚本:
- 非流式:
demo/lanyun_maas_demo.py - 流式:
demo/lanyun_maas_stream_demo.py
复现时请自行替换 Key,并以控制台当前模型名为准。
附录:本文实测环境
| 项 | 值 |
|---|---|
| 日期 | 2026-07-31 |
| 产品 | 蓝耘元生代 MaaS |
| Base URL | https://maas-api.lanyun.net/v1 |
| 主模型 | deepseek-v4-flash |
| 切换验证 | qwen3.6-flash返回「切换成功」 |
| 流式粗测 | TTFT≈1.40s,总时长≈4.67s,56 chunks(单次) |
| 非流式粗测 | 总时长≈3.73s;reasoning_tokens=94 / total=191(单次) |
| max_tokens=16 陷阱 | content='',finish_reason=length,reasoning_tokens≈16 |
| 声明 | 延迟与 Token 均为本机单次样本,非正式压测;对外请用平台日志 / AI Ping 复核 |
(注册与控制台入口以蓝耘官网最新页面为准;文中不出现个人 Key 与本机用户名。)