Django 接入阿里云百炼大模型这个需求,我在一个内部知识库问答项目里踩过一整轮坑。当时后端是 Django 3.2 + DRF,前端是 Vue,产品要求对话回复要有“打字机”效果,也就是模型边生成边把内容推给用户,不能等全部生成完再一次性返回。我一开始图省事直接同步调用大模型接口,结果用户等 5 秒白屏才看到一坨文字,体验差到爆。后来改成 SSE 流式输出,实测首字返回从 4.8 秒压到 1.2 秒左右,体感完全不一样。这篇文章就把这套方案的完整实现思路、关键代码、以及我踩过的坑全部拆开来写,适合已经在用 Django、想接大模型但不确定流式怎么做的人参考。
1. 项目整体设计与思路拆解
1.1 为什么要选阿里云百炼而不是自建或其他云
先说选型。当时团队里有两种声音,一种是基于开源模型自己部署,另一种是直接用云厂商的 API。自建这条路我直接否了,原因是团队没有 GPU 运维经验,而且知识库场景需要的是对话质量和稳定性,不是模型自由。云厂商里我们最后选了阿里云百炼,核心原因有三个:
第一,百炼平台提供的是 DashScope API,它同时兼容 OpenAI 的接口协议,这意味着我可以用 openai 的 Python SDK 直接对接,不需要额外封装一层 SDK,开发成本低。第二,百炼上的通义千问系列模型(qwen-plus、qwen-turbo、qwen-max)在中文场景下的生成质量,和同体量的开源模型相比有明显优势,尤其在知识库问答、文案生成这类任务上,中文表达能力更自然。第三,百炼的流式接口支持标准 SSE 协议,和后端 StreamingHttpResponse 的配合方式非常顺。
这里有个容易被忽略的点:百炼的控制台提供两种 API 形态,一种是非兼容模式的原生 DashScope API,地址是 https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation;另一种是兼容 OpenAI 模式的接口,地址是 https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions。如果你项目里已经装了 openai 库,走兼容模式是成本最低的路线,不用额外引依赖。
1.2 流式输出才是大模型接入的正确姿势
很多人第一次接大模型,习惯性用同步调用,也就是客户端发一个请求,服务端等模型全部生成完再把完整文本返回。这在 Demo 阶段没问题,一旦放到真实业务里就会遇到两个硬伤:
第一个是响应时间。通义千问生成一段 200 字的内容,完整生成通常需要 3 到 8 秒。如果中间不流式返回,用户就必须在这几秒里盯着一个 loading 转圈,体验非常糟糕。第二个是连接超时。常规的 HTTP 请求,网关和浏览器都有超时限制(很多环境是 30 秒),一旦模型生成时间超过这个阈值,请求就会被中断,用户什么都拿不到。
流式输出的思路是把"等结果"变成"看过程"。模型每生成一小段文本,马上推给客户端,用户看到的是像真人打字一样的连续输出。从产品视角看,这不仅是体验优化,更是在给用户传达"系统正在工作"的心理暗示。技术上,这个能力基于 SSE(Server-Sent Events)协议实现,服务端通过 Content-Type: text/event-stream 持续向客户端推送数据,直到生成结束。
用生活化的例子理解:同步调用像你去餐厅点菜,厨师做完所有菜才一起端上来,你饿着肚子干等;流式输出像火锅,涮好的毛肚第一时间夹到你碗里,边涮边吃。真实业务里,后者几乎是唯一合理的选择。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 阿里云百炼接入前的准备与核心概念
2.1 拿到 API Key 之后的必要配置
开始写代码之前,你需要在百炼控制台完成三件事:开通服务、创建 API Key、确认模型名称。这三件事在控制台的引导下十分钟内就能完成,但有几个细节值得注意。
API Key 创建后只会在弹窗里显示一次,一定要先复制保存下来,否则只能删掉重建。拿到 Key 后,我建议直接放在环境变量里,不要硬编码到代码仓库,尤其是 Django 项目多人协作时,Key 泄露到 git 历史里是非常麻烦的事。可以在项目的 .env 文件里配置:
code复制DASHSCOPE_API_KEY=sk-xxxxxxxxxxxxxxxx
然后在 Django 的 settings.py 里通过 os.environ.get("DASHSCOPE_API_KEY") 读取。
模型名称方面,百炼平台的模型命名看起来简单,实际坑不少。以通义千问系列为例:qwen-plus 是综合能力均衡的版本,适合大多数对话场景;qwen-turbo 生成速度快、成本低,适合对响应速度敏感的实时对话;qwen-max 是能力最强的版本,适合复杂推理任务。但要注意,不同模型在不同地域的可用性不一样,控制台里能看到每个模型标注的可用地域,调用前最好确认一下你选的模型在你的 API Key 所在地域是 enabled 状态,否则会报 InvalidParameter 错误。
2.2 OpenAI 兼容接口与原生 DashScope 接口的选择
百炼平台两种接口风格,我实际体验下来,兼容模式明显更省心。原因是当前主流的大模型生态工具链(比如 LangChain、LlamaIndex)默认都是按 OpenAI 接口格式封装,你如果代码里已经用了 openai 库,直接改一下 base_url 就能切换过来,一点都不折腾。
用 openai 库对接百炼兼容模式,核心初始化代码是:
python复制from openai import OpenAI
client = OpenAI(
api_key=os.environ.get("DASHSCOPE_API_KEY"),
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
)
这里要注意 base_url 不要写错。我看到不少人把地址拼成了 https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions,结果调用时报 404。正确做法是 base_url 只写到 /v1,真正的对话接口路径是 client 库自动拼接 chat/completions 的。
如果你选择原生 DashScope 接口,则需要单独安装 dashscope 库,写法上会多一些参数控制。考虑到我项目里本来就有 LangChain 相关依赖,openai 兼容模式是零额外成本的选择,所以我全套代码都基于这个模式。
2.3 同步调用与流式调用的代码对比
说清楚两种调用方式的差异,直接上代码对比最直观。同步调用的核心代码是这样的:
python复制# 同步调用,模型生成完毕后才返回
response = client.chat.completions.create(
model="qwen-plus",
messages=[
{"role": "system", "content": "你是一个专业的助手"},
{"role": "user", "content": user_question},
],
stream=False,
)
answer = response.choices[0].message.content
流式调用只需要把 stream 参数改为 True,并且遍历返回的流对象:
python复制# 流式调用,模型边生成边返回增量
response = client.chat.completions.create(
model="qwen-plus",
messages=[
{"role": "system", "content": "你是一个专业的助手"},
{"role": "user", "content": user_question},
],
stream=True,
)
for chunk in response:
if len(chunk.choices) > 0:
delta = chunk.choices[0].delta
if delta.content:
# 每拿到一小段内容,就立即推给前端
yield delta.content
这两种方式的差异就是整个流式接入的核心:同步模式把"等"留给了用户,流式模式把"等"交给了服务器和模型,用户只负责"看"。
3. Django 服务端流式输出的完整实现
3.1 接口设计与路由配置
Django 侧实现流式输出的核心是 StreamingHttpResponse。这个类允许你返回一个迭代器,Django 在响应过程中会不断从迭代器里取数据发给客户端,而不是等所有内容组装完再统一发。
我先设计一个通用的对话接口:POST /api/chat/stream,请求体是 {"message": "用户问题"},响应是一个 SSE 数据流。路由配置这样写:
python复制# urls.py
from django.urls import path
from . import views
urlpatterns = [
path("api/chat/stream", views.chat_stream, name="chat_stream"),
]
这里为什么用 POST 而不是 GET?因为对话请求需要携带用户消息,内容可能很长,POST 更合适。同时,POST 请求不会被浏览器预加载缓存,也更适合实时交互场景。
3.2 StreamingHttpResponse 与生成器函数的配合
视图函数的核心是返回一个 StreamingHttpResponse,数据源是一个 Python 生成器。生成器的 yield 关键字是整个流式输出机制的关键:每执行到 yield,生成器就把当前值抛给调用方,然后暂停,等下次 next() 再继续。
完整实现如下:
python复制# views.py
import json
import os
from django.http import StreamingHttpResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from openai import OpenAI
client = OpenAI(
api_key=os.environ.get("DASHSCOPE_API_KEY"),
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
)
@csrf_exempt
@require_POST
def chat_stream(request):
try:
body = json.loads(request.body)
user_message = body.get("message", "")
history = body.get("history", [])
except (json.JSONDecodeError, AttributeError):
return JsonResponse({"error": "请求格式错误"}, status=400)
def event_stream():
# 构造消息列表,历史消息 + 当前消息
messages = []
for item in history:
messages.append({"role": item["role"], "content": item["content"]})
messages.append({"role": "user", "content": user_message})
try:
response = client.chat.completions.create(
model="qwen-plus",
messages=messages,
stream=True,
)
for chunk in response:
if len(chunk.choices) > 0:
delta = chunk.choices[0].delta
if delta.content:
# 按 SSE 格式推送数据
payload = json.dumps({
"type": "text",
"content": delta.content,
}, ensure_ascii=False)
yield f"data: {payload}\n\n"
except Exception as e:
error_payload = json.dumps({
"type": "error",
"content": f"模型调用失败: {str(e)}",
}, ensure_ascii=False)
yield f"data: {error_payload}\n\n"
# 结束标记
yield "data: [DONE]\n\n"
response = StreamingHttpResponse(
event_stream(),
content_type="text/event-stream",
)
response["Cache-Control"] = "no-cache"
response["X-Accel-Buffering"] = "no"
response["Connection"] = "keep-alive"
return response
这段代码里有几个细节值得展开讲。
event_stream 是生成器函数,它不会在视图函数被调用时立即执行,而是在 Django 开始真正向客户端发送数据时才逐个执行 yield。所以模型调用的耗时不会阻塞视图返回,这是流式输出的关键机制。
Cache-Control: no-cache 告诉浏览器不要缓存这个响应。如果漏掉这个头,某些浏览器会缓冲所有数据,等到流结束才渲染,流式就白做了。
X-Accel-Buffering: no 这个头是针对 Nginx 的。如果你通过 Nginx 反向代理 Django,Nginx 默认会开启缓冲,把上游数据攒够再转发给客户端,这会直接破坏流式效果。加上这个头告诉 Nginx 关闭缓冲,数据才能实时透传。这个坑我在生产环境踩过,必须提前处理。
3.3 生成器里的数据库操作陷阱
如果你在生成器里进行数据库操作,比如把用户提问和模型回答写入数据库做记录,一定要小心 Django 数据库连接的问题。生成器是惰性的,event_stream 函数不会在视图返回时立即执行完,而是在响应传输过程中慢慢执行。如果数据库连接在视图返回时已经被关闭(尤其是使用连接池或短连接模式时),生成器里再查库就会报 closed connection 错误。
我的规避方案是:在视图函数返回 StreamingHttpResponse 之前,先把需要记录的对话数据同步写入数据库,生成器内只专注做流式转发,不做任何 IO 操作。如果后续确实需要在生成器里查库,比如根据用户 ID 查询历史记录,一定要在生成器内部重新获取连接,或者用 close_old_connections() 清理陈旧连接。
3.4 超时控制的处理技巧
流式响应还有一个容易被忽视的问题:长时间没有数据推送时,服务端连接可能被中间层断开。大模型在思考复杂问题时会有一段"静默期",如果这段时间超过 Nginx 或客户端的超时阈值,连接就断了,用户看到的是"流中断"。
我的做法是在生成器里加一个心跳机制,如果模型超过 10 秒没有返回任何增量,就手动推送一个保持连接的空数据:
python复制import time
def event_stream():
last_yield_time = time.time()
try:
response = client.chat.completions.create(...)
for chunk in response:
if len(chunk.choices) > 0:
delta = chunk.choices[0].delta
if delta.content:
payload = json.dumps({"type": "text", "content": delta.content}, ensure_ascii=False)
yield f"data: {payload}\n\n"
last_yield_time = time.time()
# 超时保活
if time.time() - last_yield_time > 10:
yield ": keep-alive\n\n"
last_yield_time = time.time()
except Exception as e:
...
这里 : keep-alive 是 SSE 协议的注释行,客户端会忽略它,但能维持连接活跃。
4. 前端对接 SSE 流与 Markdown 渲染
4.1 用 fetch 解析 SSE 数据流
后端准备好了,前端怎么接?很多教程推荐用 EventSource 对象,但 EventSource 只能处理 GET 请求,而且不支持自定义请求头,没法携带 token 做鉴权。我们的场景是 POST + JSON,所以更适合用 fetch 配合 ReadableStream 手动解析。
核心代码如下:
javascript复制async function sendMessage(message) {
const response = await fetch('/api/chat/stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ message }),
});
if (!response.ok) {
throw new Error(`请求失败: ${response.status}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// 按 \n\n 分隔 SSE 事件
const lines = buffer.split('\n\n');
buffer = lines.pop();
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6);
if (data === '[DONE]') continue;
try {
const parsed = JSON.parse(data);
if (parsed.type === 'text') {
// 将增量内容追加到对话框
appendContent(parsed.content);
} else if (parsed.type === 'error') {
showError(parsed.content);
}
} catch (e) {
console.error('解析SSE数据失败', e);
}
}
}
}
}
这段代码里最关键的技巧是 buffer 的处理。reader.read() 每次返回的字节流不保证正好是一个完整的 SSE 事件,可能一条事件被拆成两次读取,也可能一次读取包含多个事件。所以必须用 buffer 缓存不完整的数据,等到 \n\n 出现时才认为这是一个完整事件。这个处理如果写错,前端会随机出现"内容乱码"或"缺字"。
另外注意到我用 decoder.decode(value, { stream: true }),这个参数很关键。因为流式数据可能把多字节字符切成两段,比如一个中文汉字 UTF-8 编码占用三个字节,如果第一次读取只拿到前两个字节,用 stream: true 可以让 TextDecoder 保留不完整的字节,等到下一段数据到达时再拼接解码。不加这个参数,中文内容偶尔会变成乱码。
4.2 Markdown 渲染的实时更新策略
大模型的回复通常是 Markdown 格式,前端如果直接渲染原始文本,代码块、列表、加粗这些样式会全部丢失。但如果把每段增量直接拼进 Markdown 并重新渲染整个内容,又会有两个问题:一是频繁操作 DOM 导致性能下降,二是 Markdown 在"不完整"状态下(比如代码块只写了一半)会被解析成错误的 HTML。
我采用的分层策略是:原始文本按增量拼接展示,同时用 marked 库将完整文本解析为 HTML 后渲染。但为了性能,我做了节流——不是每次收到增量都重渲染,而是每 50 毫秒或每积累一定长度再更新一次视图。Vue 项目里可以用 computed 属性绑定解析后的 HTML,React 项目里可以用 useMemo 缓存转换结果。
核心代码(Vue 3 组合式写法):
javascript复制import { ref, computed, watch } from 'vue';
import { marked } from 'marked';
const streamingText = ref(''); // 原始 Markdown 文本
const renderedHTML = computed(() => marked.parse(streamingText.value));
// 节流更新,避免高频重渲染
let renderTimer = null;
function appendContent(text) {
streamingText.value += text;
if (renderTimer) clearTimeout(renderTimer);
renderTimer = setTimeout(() => {
// 强制触发 computed 更新
document.getElementById('answer-content').innerHTML = renderedHTML.value;
}, 50);
}
这里有个经验:代码块在流式过程中会有"闪烁"现象。因为 Markdown 解析器遇到不完整的代码块边界时,会先渲染成普通文本,等完整了又变成代码块样式,视觉上闪来闪去。我现在的方式是不做特殊处理,让它在流式过程中自然呈现,流结束时整体格式会稳定。如果你对效果有更高要求,可以在渲染时给代码块加渐入动画,或者把代码块区域单独提取出来等完整后再渲染。
4.3 中断与重试机制
用户点击停止生成后,前端需要主动关闭流。fetch 的 ReadableStream 可以通过 reader.cancel() 取消读取,同时需要通知后端停止模型生成。我是在前端取消后,额外发送一个中断请求到 /api/chat/stop,后端用一个全局字典记录每个用户的生成会话 ID,中断时调用百炼的终止接口。对于真实业务,这一步不能省略,否则用户点了停止,模型还在后台空转,浪费 token。
重试机制方面,我的做法是对网络错误做一次自动重试,但对模型返回的业务错误不重试。比如 API 返回 401(Key 失效)、400(参数错误)这些直接提示用户;只有网络超时、连接断开这类瞬时错误才自动重试,重试条件是在建立连接后 3 秒内没有收到任何数据。
5. 生产环境部署的关键细节
5.1 Gunicorn worker 类型对流式的影响
Django 项目常用 Gunicorn 部署,但 Gunicorn 的同步 worker 对流式响应非常不友好。默认的 sync worker 一次只能处理一个连接,如果某个请求长时间不结束(流式响应正是这种情况),这个 worker 就被占死了,其他请求全部排队。并发一高,服务直接雪崩。
我的解决方案是使用 gthread worker 模式,允许每个 worker 通过线程处理多个并发连接:
bash复制gunicorn myproject.wsgi:application \
--workers 4 \
--threads 8 \
--timeout 300 \
--worker-class gthread \
--keep-alive 5
参数说明:--threads 8 表示每个 worker 开启 8 个线程,4 个 worker 最多同时处理 32 个连接。--timeout 300 是关键,默认的 30 秒超时会在模型生成时间较长时杀掉 worker,必须调大。--keep-alive 5 控制与客户端的长连接保持时间。
如果你用 Docker 部署,还需要注意容器内的环境变量传递。Dockerfile 里要把 API Key 通过环境变量注入,避免把 Key 写死在镜像里。
5.2 Nginx 缓冲与代理配置
只要你的 Django 服务前面有 Nginx,就必须处理缓冲问题。Nginx 默认开启 proxy_buffering on,它会把后端返回的数据攒到缓冲区,等整个响应结束再一次性发给客户端。这会直接毁掉流式效果。
正确的 Nginx 配置片段:
nginx复制location /api/chat/stream {
proxy_pass http://django_backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
proxy_buffering off 是核心,关闭缓冲后 Nginx 会将上游数据实时转发给客户端。proxy_set_header Connection "" 是为了清理默认的 keep-alive 头,避免和后端连接冲突。proxy_read_timeout 调大到 300 秒,防止模型思考时间过长时 Nginx 提前断开。
Django 侧还需要在响应头里设置 X-Accel-Buffering: no,这个头在代码里已经加了,双保险。
5.3 打包部署的注意事项
我遇到一个部署时才暴露的问题:百炼 SDK 依赖的 httpx 库在 Python 3.7 环境下会安装失败,报编译错误。后来查了一下,新版 httpx 的依赖 httpcore 引入了需要编译的特性和某个较新的 C 库有关。解决方案是固定 SDK 版本,在 requirements.txt 里锁定:
code复制openai==1.35.0
httpx==0.27.0
httpcore==1.0.5
版本锁定一定要做,不锁版本,依赖一升级,代码可能就挂了。我在测试环境遇到过 openai 库从 1.x 升级到 2.x 后,base_url 参数的传法完全变了,接口直接 404。生产环境务必用 requirements 锁定版本,并经过测试环境验证后再发布。
6. 常见问题与排查技巧实录
6.1 问题速查表
我整理了一份实际开发和上线过程中遇到的问题速查表,每一个都是真实踩过坑才总结出来的。
| 症状 | 可能原因 | 解决方案 |
|---|---|---|
| 前端长时间无响应,F12 看到请求 pending | Nginx 缓冲未关闭 | 在 nginx 配置加 proxy_buffering off |
| 收到内容延迟很久才一次性展示 | 浏览器缓冲或 Nginx 缓冲 | 检查 Cache-Control 和 X-Accel-Buffering 响应头 |
| 中文偶尔出现乱码 | TextDecoder 未使用 stream: true |
解码时设置 { stream: true } |
| 流中断,没有报错 | 服务端超时或连接被中间层断开 | 增加心跳保活,调大 Nginx 和 Gunicorn 超时 |
| 模型调用报 404 | base_url 写错 | 只写到 /v1,不要拼接具体路径 |
| 生成器内查数据库报连接错误 | 数据库连接在生成器惰性执行时已关闭 | 把数据库操作移到视图函数中,或重新获取连接 |
| 高并发下服务卡死 | Gunicorn sync worker 被流式请求占满 | 切换到 gthread worker,合理配置 threads |
| API Key 泄露在前端代码 | 前端直接调用云 API | 统一走后端接口,前端不持有任何云凭证 |
6.2 流式响应速度优化的三个方向
首字延迟是流式体验的核心指标。我实测了三种模型在相同问题下的首字返回时间:qwen-turbo 大约 0.8 秒,qwen-plus 大约 1.2 秒,qwen-max 大约 2.5 秒。如果对首字延迟特别敏感,可以在业务场景允许的前提下选用 qwen-turbo。
但不能只看首字延迟,要看"首字到完整输出的平均速度"。qwen-turbo 虽然首字快,但生成后期速度会慢下来;qwen-plus 的整体生成速率更稳定。具体选哪个,得贴合业务判断。
另一个优化方向是减少中间层跳数。如果 Django 服务和百炼接口之间经过多层代理,每一层都会增加几十毫秒的延迟。我的部署方案是 Django 服务直接通过公网调用百炼,不做中转代理,网络开销控制在可接受范围。
6.3 调试流式接口的独家方法论
流式接口调试比普通接口麻烦,因为你没法在浏览器地址栏直接看效果。我的经验是先用命令行模拟 SSE 客户端,验证后端接口是否正常:
bash复制curl -N -X POST http://127.0.0.1:8000/api/chat/stream \
-H "Content-Type: application/json" \
-d '{"message": "你好,介绍一下你自己"}'
加 -N 参数是禁用 curl 的缓冲,让数据一到就立即打印。如果这里能看到逐字输出的效果,说明后端是好的,问题大概率在前端。
排查 Nginx 相关问题时,我会直接在服务器上 curl 内网地址,对比公网地址的响应差异,快速定位是哪一层引入了额外延迟。
还有一个技巧:在生成器里加日志,记录每次 yield 的时间戳和内容长度。上线后遇到用户反馈"回复很慢"时,拉日志就能看出是模型生成慢、网络传输慢还是前端渲染慢,不用靠猜。
7. 后续扩展方向与个人体会
这个流式通道接通以后,实际价值比我预想的大得多。知识库问答只是最初的应用,后来我把同一个流式接口复用到多个场景:对话式搜索、AI 写作助手、代码审查建议。每次场景切换只需要调整 system prompt 和消息结构,不用改传输层代码。
我个人实际操作中的体会是:流式接入的难点不在协议本身,而在全链路视角。从 Django 视图到 Gunicorn,从 Nginx 到浏览器,任何一层没有正确配置,流式效果都会大打折扣。技术方案选型时,优先选那些生态成熟、社区方案多的路径(比如 OpenAI 兼容协议),能帮你避开大量隐性坑。
最后分享一个小技巧:如果你在开发阶段觉得每次等模型生成太慢,可以先用一段固定的 mock 数据测试流式链路,把 event_stream 生成器临时替换为一个每 100 毫秒 yield 一截文本的模拟函数。这样可以在不影响真实逻辑的情况下,快速验证 Django、Nginx、前端三层是否配置正确。等链路全部打通,再换回真实模型调用,能省下大量调试时间。
