最近后台收到好几条私信,都在问同一个问题:怎么把 ChatGPT 的对话能力塞进自己的 Flask 项目里。有人是想给内部管理系统加个智能助手,有人是拿来做课程设计的 demo,也有人单纯是想把 AI 聊天功能和你自己写好的业务逻辑串在一起。这个需求太典型了,我今天就完完整整写一遍。
先说清楚这事的核心逻辑。ChatGPT 本身是一个对话模型服务,它对外提供的是一个 HTTP 接口。所谓“在 Flask 应用中实现”,本质就是写一个路由,把这个接口和你自己的 Web 服务对接起来。听起来简单,但真正动手时你会发现有一堆细节:接口鉴权怎么处理、对话上下文怎么保存、流式输出怎么做、请求超时了怎么兜底。这篇文章会从零到一,把这些坑全部趟一遍。
我会假设你已经有基础的 Flask 使用经验,知道怎么建路由、怎么跑一个最小应用。如果你连 Flask 都没装过,问题也不大,跟着第一步走就能跑起来。
1. 整体方案与技术选型:先解决“怎么聊”的问题
1.1 前端直连还是后端中转,为什么必须选后端中转
先看一个常见的错误思路。有些人图省事,想在前端页面里直接调用 ChatGPT 的接口,这样后端代码几乎不用写。我劝你趁早打消这个念头。直接在前端调接口,你的密钥就暴露在浏览器里了,别人打开开发者工具就能把你的密钥抄走。更麻烦的是,浏览器跨域请求会直接被拦下来,你还得去处理 CORS 策略,而模型服务的官方接口通常不会允许你的域名随意跨域调用。
正确且稳妥的做法是让 Flask 后端当中转层。前端把用户输入的内容发给你的 Flask 服务,你的 Flask 服务再拿着密钥去请求模型接口,拿到结果后返回给前端。这样做有三个好处:
- 密钥只保存在服务端,用户永远接触不到
- 你可以在后端自由地加业务逻辑,比如查询数据库、调用你自己的工具函数、做权限校验
- 你可以统一处理错误、日志、限流、缓存,这些在前端直连的场景里几乎做不到
这套架构对一个小型应用来说已经足够清晰,它就是一条单向管道:浏览器 -> Flask 路由 -> 模型接口 -> Flask 返回流 -> 浏览器渲染。整条链路里最难的部分,其实是最后一个“返回流”的环节,我后面会专门用一整章来讲。
1.2 技术栈的选择与理由
我用的技术组合是 Flask + requests(或 openai 官方 SDK)+ 原生 JavaScript,外加一个简单的 HTML 页面。没有引入任何重型前端框架。
为什么不用 Vue 或 React?因为我们的核心诉求是给现有 Flask 应用加一个 AI 对话能力,而不是重新写一个前端单页应用。用原生 JS 写一个消息列表和一个输入框,代码量不会超过一百行,放到 Jinja2 模板里就能跑,学习成本低,也方便你迁移到自己已有的页面中。
模型的接入方式有两种选择。一种是直接用官方的 Python SDK,接口封装得比较完善,开箱即用,但版本更新快,偶尔会有 breaking change。另一种是用 requests 库直接调 HTTP 接口,逻辑完全由自己控制,灵活度和可维护性反而更高,而且不需要额外安装 SDK。我最终选择了创建chat_service.py这个模块,对模型调用做一层薄封装。这样后面无论是换模型还是切换接口版本,都只改一个文件,其他代码完全不用动。
1.3 项目结构规划
动手写代码之前,先把目录规划好。这点很重要,不然你写到一半就会陷入“到底哪个文件放在哪里”的混乱中。我的最小结构是这样:
flask-chat-demo/ ├── app.py # Flask 应用入口,注册路由 ├── chat_service.py # 模型接口调用封装 ├── requirements.txt # 依赖列表 ├── templates/ │ └── index.html # 前端聊天页面 └── static/ └── style.css # 页面样式(可选)这个结构对应的是一个极简的 Flask 应用,没有用到蓝图和工厂模式。如果你的项目本身已经有比较完整的模块划分,就把chat_service.py放到你的服务层目录,把index.html放到对应的模板目录,把新加的路由挂到现有的蓝图上。核心思想是一致的:职责分离,模型调用逻辑不要直接散落在视图中。
2. 流式输出:这是“用起来像 ChatGPT”的关键
2.1 为什么要做流式输出,普通返回差在哪
很多人第一次做完聊天接口,发出来的效果是:用户发一句话,页面转圈 5 到 10 秒,然后一大段文字突然蹦出来。这种体验和 ChatGPT 原版差距非常明显,原因就出在返回方式上。
ChatGPT 原版的效果是“一个字一个字往外蹦”,背后用的是流式传输。模型服务端一边生成,一边把已经生成的文本片段推送给客户端。让用户看到的内容就是持续的增量,第一句话刚生成几个词,显示区域就开始滚动更新。这样做的直接好处是用户感知等待时间从“几秒钟”降到“几百毫秒”,心理体验完全不同。
在 Flask 里实现流式返回,最优雅的方案是使用生成器函数配合Response对象。你不需要一次性把完整结果算完再返回,而是yield一段、推一段。Flask 的Response接收一个生成器,会自动按块读取并发送给客户端。
2.2 SSE 与普通 chunked 响应的区别,我为什么选择这种方式
这里需要澄清一个概念。很多人一听到流式就想到 SSE(Server-Sent Events),两者有关联但不是一回事。
SSE 是一种标准协议,基于 HTTP,服务端通过Content-Type: text/event-stream推送事件。如果要对接EventSourceAPI,就必须按 SSE 格式组织数据。而一个普通的流式响应,只需要返回text/plain或text/html,用分块传输编码(chunked)即可,浏览器也可以通过fetch配合ReadableStream读取。
对于聊天场景,两种方案都能用。SSE 的优势是有标准化的event、data、id字段,比较适合更复杂的消息推送场景。普通 chunked 响应实现起来更直接,你只需要在后端yield,前端读取response.body.getReader(),代码量最少。
我最终选择了后者。原因很简单:代码清晰、不易出错、浏览器兼容性也没问题。你完全可以理解为“极简版的流式接口”,实现门槛低,也更容易在现有业务中维护。
提示:如果你后面想对接更规范的流式服务,思路是相同的,只是
yield时需要拼接data: {json}\n\n这种格式。很多企业级大模型服务都兼容这一规范,了解它会让你少踩不少坑。
2.3 Flask 流式返回的核心代码原理
先看后端的关键代码框架,忽略具体的模型调用细节,只看流式返回的骨架:
from flask import Response def generate_chunks(prompt): # 这里模拟一个耗时较长的处理过程 for i in range(10): time.sleep(0.2) yield f"第{i+1}段内容 " @app.route("/chat") def chat(): prompt = request.args.get("prompt", "") return Response(generate_chunks(prompt), mimetype="text/plain")如果你把这段代码跑起来,用浏览器访问/chat?prompt=你好,你会看到页面不是一次性加载完的,而是每隔 0.2 秒多一段文字,直到底部不再变化。这就是流式返回的基本效果。
如果对接真实的模型服务,只需要把generate_chunks里的模拟逻辑换成真正的流式接口调用即可。模型服务端本身已经提供了多次 yield 的能力,你要做的事情就是将它们透传出来。
Response的关键在于它会迭代传入的生成器对象。只要生成器没有被耗尽,连接就会一直保持开启,客户端也就能持续收到数据。需要注意的是,mimetype必须正确设置,否则某些浏览器或网络代理可能会缓冲全部内容后才一次性展示,流式效果就失效了。
2.4 缓冲问题:你遇到“不流式”时,先查这里
在实际测试中,很多人会遇到一个问题:后端明明已经使用yield了,但前端还是等到全部生成完才显示。导致这个问题的元凶是缓冲。
第一层是 Python 层的缓冲。默认情况下,sys.stdout和网络传输可能会缓冲数据。在开发环境用 Flask 自带的开发服务器时,你可以关闭flask run的缓存来调试,也可以使用--no-reload等参数调整。最为直接的方法是确认你已经用生成器,而不是在视图函数里return "完整字符串"。
第二层是 Web 服务器层的缓冲。如果你后面用了gunicorn,需要在启动命令中增加--threads之类的适当配置,以及确保没有意外拦截流式响应。Nginx 默认可能会打开缓冲,需要显式关闭proxy_buffering。这是部署阶段的内容,第三章后面会详细说。
第三层是浏览器的 fetch 行为。有时不是网络层问题,而是你没有正确使用可读流。如果你直接response.json(),那无论如何都不会流式。必须使用response.body.getReader()才能读到增量数据。
这些知识不实际踩一遍很难记住。我把它们全部放在这里,后面遇到“内容不流式、一次性全出来”,你就按这个顺序排查。
3. 核心代码实现:从后端到前端,把整条链路打通
3.1 安装依赖与基本结构
我来给出一个可以直接运行的最小项目。首先创建虚拟环境并安装依赖:
python -m venv venv source venv/bin/activate # Windows 下为 venv\Scripts\activate pip install flask requests项目只需要这两个第三方库。requests用来发起 HTTP 请求,flask用来提供 Web 服务。如果你的网络环境里已经有很多包,也不会有冲突,因为这两个库非常基础。
接着创建app.py,这一步先写一个最小化但完整的 Flask 应用:
from flask import Flask, render_template, request, Response app = Flask(__name__) @app.route("/") def index(): return render_template("index.html") @app.route("/chat", methods=["POST"]) def chat(): data = request.get_json() prompt = data.get("prompt", "") messages = data.get("messages", []) # 调用封装好的模型服务 from chat_service import stream_chat generator = stream_chat(prompt, messages) return Response(generator, mimetype="text/plain") if __name__ == "__main__": app.run(debug=True)这个chat路由接收一个 JSON,字段包含prompt(用户当前输入)和messages(历史消息列表)。注意我把messages作为参数传给服务层,这样上下文管理可以完全放在服务端处理。这里使用POST而不是GET,是因为POST更适合携带较长文本和 JSON 结构。
3.2 模型调用封装:chat_service.py 的完整写法
现在是最关键的一层,chat_service.py。这一层封装了与模型接口交互的细节。你需要把它理解成一个“翻译器”:把我们的请求格式翻译成模型接口的格式,再把模型的流式返回翻译成 Flask 可以透传的文本流。
import requests import json # 从环境变量或配置中读取,不建议硬编码 API_KEY = "your-api-key-here" API_URL = "https://api.example.com/v1/chat/completions" def _build_headers(): return { "Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json", } def _build_payload(prompt, messages): # 将用户输入追加到历史消息中,构造完整的消息列表 all_messages = messages + [ {"role": "user", "content": prompt} ] return { "model": "gpt-3.5-turbo", "messages": all_messages, "stream": True, # 核心开关,开启流式响应 "temperature": 0.7, } def stream_chat(prompt, messages): payload = _build_payload(prompt, messages) response = requests.post( API_URL, headers=_build_headers(), json=payload, stream=True, # 必须以流式方式发起请求 ) if response.status_code != 200: error_message = f"请求失败:HTTP {response.status_code} - {response.text}" yield f"[错误] {error_message}" return # 逐行读取服务端返回的流式数据 for line in response.iter_lines(): if not line: continue # 标准流式数据格式类似 data: {json} line = line.decode("utf-8") if line.startswith("data:"): data_str = line[5:].strip() if data_str == "[DONE]": break try: chunk_json = json.loads(data_str) content = chunk_json["choices"][0]["delta"].get("content", "") if content: yield content except json.JSONDecodeError: continue代码里有几个细节值得注意。stream=True这个参数决定了requests不会等待全部响应完成后才返回,而是边读取边 yield。response.iter_lines()会逐行迭代,每行是一个数据帧。在 OpenAI 兼容的流式协议中,每个数据帧以data:开头,内容是一个 JSON 字符串,choices[0].delta.content就是本帧新增的文本片段。如果你对接的模型服务是这种协议,这段代码可以直接复用。
如果出现指定参数缺失或者API_KEY未设置,_build_payload里可以增加校验。这类防御性编码实际上非常重要,尤其是当接口变动时,错误提示能让你快速定位是客户端问题还是服务端问题。
3.3 前端页面:用原生 JavaScript 接收并展示流式数据
后端已经可以把内容一段一段吐出来了,前端的工作就是接收这一串数据流并实时渲染。我使用的是fetch的流式读取能力。注意,fetch本身就支持流式读取,这一点很多前端同学可能不太熟悉,因为我们平时用fetch().then(res => res.json())更多。
index.html的简化模板如下:
<!DOCTYPE html> <html lang="zh"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>AI 对话助手</title> </head> <body> <div id="chat-box"></div> <input type="text" id="prompt-input" placeholder="请输入你的问题..."> <button id="send-btn">发送</button> <script> document.getElementById("send-btn").addEventListener("click", sendMessage); function appendMessage(role, content) { const chatBox = document.getElementById("chat-box"); const div = document.createElement("div"); div.className = "message " + role; div.textContent = content; chatBox.appendChild(div); return div; } async function sendMessage() { const input = document.getElementById("prompt-input"); const prompt = input.value.trim(); if (!prompt) return; // 先在界面上显示用户消息 appendMessage("user", prompt); input.value = ""; // 服务端返回的文本块用流式方式读取 const response = await fetch("/chat", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ prompt: prompt, messages: [] }), }); if (!response.ok) { appendMessage("assistant", "请求失败:" + response.status); return; } // 创建一个空消息容器,后续内容不断追加 const assistantEl = appendMessage("assistant", ""); const reader = response.body.getReader(); const decoder = new TextDecoder("utf-8"); while (true) { const { done, value } = await reader.read(); if (done) break; // 将二进制数据解码为文本,追加到助理消息中 const text = decoder.decode(value, { stream: true }); assistantEl.textContent += text; // 滚动到底部 document.getElementById("chat-box").scrollTop = document.getElementById("chat-box").scrollHeight; } } </script> </body> </html>这段代码的核心是response.body.getReader()。它返回一个可读流的读取器,每次reader.read()会得到一个{ done, value }对象。done为true表示流已经结束,value是一个Uint8Array,需要用TextDecoder解码为字符串。将这些增量文本不断追加到消息容器中,就实现了动态打字效果。
一个容易被忽略的点是:appendMessage("assistant", "")创建的是一个空元素,之后你不断修改它的textContent。这是实现“增量更新”最简单的办法。如果你使用innerHTML,要注意 XSS 风险;对于普通文本场景,建议始终使用textContent。
3.4 引入历史消息:上下文关联的实现
上面的例子中,前端每次请求都传了一个空的历史消息列表。这意味着模型无法记住之前的对话内容,每次都是独立的问答。要让它“记住”上下文,有两种做法。
第一种是前端把全部历史消息传给后端。前端维护一个messages数组,每完成一轮对话就把{role: "user", content: ...}和{role: "assistant", content: ...}都推进数组,再次请求时一并传过去。这种做法的优点是实现简单、状态透明,但缺点是你必须在前端保存全部历史消息,如果消息非常长,请求体就会越来越大。
第二种是后端维护会话状态。在 Flask 中使用session或使用数据库保存对话记录。请求进来时从后端读取历史,拼接消息列表,完成一轮后再存入。这样前端无需关心上下文细节,只需要传递一个会话 ID。
对大多数中小型项目,我推荐第一种过渡方案。它不依赖额外的存储,代码改动最小。当你意识到历史消息已经大到影响请求效率时,再迁移到后端持久化方案。
下面是加了历史消息发送的修改版前端代码片段:
let messageHistory = []; async function sendMessage() { const input = document.getElementById("prompt-input"); const prompt = input.value.trim(); if (!prompt) return; messageHistory.push({ role: "user", content: prompt }); const response = await fetch("/chat", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ prompt: prompt, messages: messageHistory.slice(0, -1), // 发送本次输入之前的历史 }), }); // ... 读取流式返回 ... // 等消息接收完成后,将完整回复加入历史 // 注意:你需要保存本轮助手回复的完整文本,而不是只保存增量 // 因此在流式读取过程中,需要一个变量累积所有收到的文本 }这段代码的关键是处理“历史消息不包含当前输入”。由于后端已经将messages和prompt拼接,这里传过去的messages应该是“当前这轮输入之前”的全部消息,然后把prompt单独传给后端去拼接。别传重复了,否则模型会看到两条相同内容的用户消息。
注意:一旦你开始传历史消息,封装函数里的
_build_payload就必须保证消息顺序。先有系统设定(如果有的话),再有早期的用户和助手对话,最后附加最新的用户输入。顺序错乱会导致对话逻辑非常诡异。
4. 请求超时、重试与错误处理:流式接口的稳定性保障
4.1 理解请求超时参数
网络请求并不是无限等待的。如果模型服务端长时间没有返回任何数据,你的requests.post会一直挂着。默认情况下,requests没有设置超时,这意味着如果服务端出问题,你的 Flask 进程也可能被拖死。这个问题在并发稍高时特别致命。
建议在调用时刻意设置两个超时参数。connect_timeout是建立连接时的超时时间,一般设置 5 到 10 秒足够;read_timeout是从响应中读取数据块时的超时时间,对于流式响应,这个参数更关键。模型生成一句完整回答可能需要十几秒甚至半分钟,但期间只要在持续输出数据,read_timeout就不应该触发。我建议把read_timeout设置在 30 到 60 秒。
下面是修改后的调用:
response = requests.post( API_URL, headers=_build_headers(), json=payload, stream=True, timeout=(10, 60), )如果你的场景很特殊,比如模型需要思考较长时间或网络延迟很高,可以把read_timeout调大。总而言之,超时时间是一个需要根据实际测试调试的参数,不可能一个通用值走天下。
4.2 模型接口返回异常时的兜底策略
当模型接口不可用时,你不能让用户的请求一直卡在那里。务实的策略是在stream_chat中捕获异常,并把错误信息转换为通常的文本流返回给前端。这样用户看到的只是“回复内容有误”,而不是页面无限加载。
def stream_chat(prompt, messages): try: response = requests.post(...) if response.status_code != 200: yield f"[错误] 模型服务返回错误码:{response.status_code}" return for line in response.iter_lines(): ... except requests.exceptions.Timeout: yield "[错误] 请求超时,请稍后重试。" except requests.exceptions.ConnectionError: yield "[错误] 无法连接到模型服务,请检查网络。" except Exception as e: yield f"[错误] 发生未知异常:{str(e)}"在流式响应进行到一半时如果出现异常,你也要给前端一个明确的结束信号。有些人会在finally里补充一句yield "\n\n[已中断]",这样用户体验会好很多。
需要留意的是,一旦你 yield 了错误字符串,前端就会把这段内容显示出来。所以用户能直接看到“请求超时”之类的提示,而不是干等。我的经验是,错误文案要尽量温和且可操作,例如“模型暂时繁忙,请试试重新发送”。直接暴露 HTTP 状态码和原始错误信息,更适合排查问题时临时使用,不建议长期保留在线上环境。
4.3 重试机制要不要做、怎么做
如果你的模型服务偶尔抖动,重试是一个好方法。但重试策略需要讲究,不能一失败就立刻重发,否则可能加重服务端压力。
我当时只做了一级重试:如果首次请求因为连接错误或 429 限流而失败,等待 1 秒后重试一次。如果重试仍然失败,就不再继续,直接向用户返回错误提示。避免无限重试是底线,因为无限重试会让接口拥塞恶化,用户也会陷入“一直在加载”的糟糕体验。
这部分的代码不复杂,但意义重大。它决定了你的应用在模型服务不稳定时是表现为“缓慢但可用”,还是“偶尔不可用”。就我自己在真实环境里的观察,这个差别直接影响用户对系统的整体评价。
5. 部署与性能优化:从开发环境走向生产环境
5.1 不要用 Flask 自带开发服务器扛并发
Flask 自带的应用服务器(app.run())只是为开发设计的,它的并发能力非常有限。当它处理流式请求时,一个长连接就占用一个 worker,如果你的应用是一个内部小工具,可能还无所谓。但如果是部署在公网或有较多用户访问,你得切换到专业的 WSGI 服务器。
我使用的方案是gunicorn(Linux 环境)+geventworker。启动命令参考:
gunicorn -w 4 -k gevent --timeout 120 app:app这里-w 4表示启动 4 个 worker 进程,-k gevent使用协程 worker 来支持高并发长连接,--timeout 120是 worker 超时时间。为什么timeout要调大?因为流式请求的时长通常会超过普通 HTTP 请求的默认超时时间(比如默认 30 秒),如果你的模型生成时间较长但 worker 超时时间设置太短,worker 会被强制杀死,用户的连接也会被中断。
如果你使用的是 Windows 环境,gunicorn不可用,那么可以用waitress。它也是一个纯 Python 的 WSGI 服务器,但它在长连接和并发处理上的表现比 Flask 自带的开发服务器要可靠得多。
5.2 Nginx 反向代理时的缓冲开关
很多人在部署时会加一层 Nginx 做反向代理。如果你没开任何配置,Nginx 默认会对上游响应做缓冲。它会把 Flask 发送的每一小块内容收集起来,攒到一定程度再发给客户端。这样你的流式输出效果全被吞掉,用户看到的又是“等了很久然后一次性出现”。
你必须在 Nginx 的 location 配置中显式关闭代理缓冲,或者针对/chat这个路径单独设置:
location /chat { proxy_pass http://127.0.0.1:8000; proxy_buffering off; proxy_cache off; proxy_set_header Connection ''; proxy_http_version 1.1; chunked_transfer_encoding on; }这段配置每次部署时都必须检查。我印象中自己第一次部署后,前端表现与本地测试完全不同,排查了半小时,最后才意识到是 Nginx 的缓冲在作怪。如果你发现本地 ok、线上不流式,第一反应就来看这里。
5.3 使用 Redis 做接口限流和缓存
对于生产环境,特别是你的 Flask 应用需要开放给多个用户使用时,限流是一个非常重要的保护措施。模型接口是按量计费的,如果没有限流,一个用户不断刷新发送大量消息,账单可能会让你措手不及。
最轻量的方案是在 Flask 层写一个简单的滑动窗口限流,用time模块记录每个用户 ID 的最近请求时间。实现简单,但只适合单进程运行。如果你的服务跑在多 worker 下,进程之间不共享内存,限流就不准确了。这时可以引入 Redis,用它的INCR和EXPIRE实现计数器限流。
这段代码我就不完整贴了,限流的核心逻辑是:每个用户在一个时间窗口内只能发起 N 次请求,超限就直接返回 429。这个交通管制的思想,在一开始就把流量控制在安全水位。
5.4 将敏感配置放入环境变量
把 API 密钥硬编码在源代码里只适合本地体验。一旦代码提交到 Git 仓库,密钥泄露只是时间问题。
建议把API_KEY和API_URL放到环境变量中:
export CHAT_API_KEY="your-api-key" export CHAT_API_URL="https://api.example.com/v1/chat/completions"然后代码中这样读取:
import os API_KEY = os.environ.get("CHAT_API_KEY", "") API_URL = os.environ.get("CHAT_API_URL", "") if not API_KEY or not API_URL: raise RuntimeError("缺少必要的环境变量配置")这种做法既安全又灵活。换环境时只需要重新导出环境变量,源码不用动。如果你的项目用 Docker 部署,可以在docker-compose.yml的environment段或--env-file中配置。
6. 上下文长度管理与系统提示词:让对话不再“失忆”
6.1 Token 超限问题怎么处理
当你开始使用历史消息后,很快会遇到一个经典问题:消息太长,超过模型接口的单次请求 token 上限。比如模型限制单次请求 4096 个 token,你的用户聊了 50 轮之后,把全部历史消息拼进去肯定超限。
解决方案有三个层级,从简单到复杂排列。
第一层:截断过旧的消息。保留最近的 N 轮对话,更早的内容直接丢弃。消息列表从前往后裁,每次保留最近 10 轮或 20 轮。这是最实用也最见效的方案。
第二层:压缩历史消息。用一个简单的摘要模型把早期对话总结成三句话,放进系统提示词中,再开始当前这轮对话。这样既保住了核心信息,又控制了 token 消耗。
第三层:使用向量数据库做外部记忆。把历史对话向量化存储,每次只检索与当前问题相关度最高的记忆片段。这个方案适用于构建大型知识库型助手,小项目暂时用不上。
我建议先做第一层,配合合理的轮次阈值,在开发环境多测试几次,直到“能稳定聊 20 轮不超限”,再考虑是否需要压缩历史。
6.2 系统提示词的设计:固定角色与业务规则
提示:很多初学者忽略了“系统消息”的作用。系统提示词相当于给 AI 设定一个人设和业务规则。在消息列表的最前面,你可以加入一条系统消息,例如:“你是一个负责帮酒店前台查订单的助手,必须用简洁的表格回复客人,不得编造订单信息。”
有了系统提示词,你的助手就不会是一个通用聊天机器人,而是真正服务于你业务场景的机器人。这个提示词同样要注意 token 开销,它会在每轮请求时被重复发送。建议把它控制在合理范围内,不要写几百字的提示词。
_build_payload中的消息列表构造成这样:
def _build_payload(prompt, messages): system_prompt = "你是一个业务助手,请用中文简洁准确地回答问题。" all_messages = [ {"role": "system", "content": system_prompt} ] + messages + [ {"role": "user", "content": prompt} ] return { "model": "gpt-3.5-turbo", "messages": all_messages, "stream": True, "temperature": 0.5, }6.3 温度参数与随机性控制
temperature参数控制模型输出的随机程度。数值在 0 到 1 之间,0 表示每次输出尽可能保守稳定,1 表示更有创造力。如果你做的是客服类对话,建议设为 0.2 到 0.5,避免模型自由发挥。如果你做的是文案创作、头脑风暴,可以调到 0.8 左右。
这个参数值得花时间测试。同一句用户输入,在不同temperature下的结果差异会很明显。我的经验是,做工具类应用时尽量压低温度,能有效减少“一本正经地胡说八道”的概率。
7. 常见问题排查实录:我踩过的那些坑
7.1 返回内容一直不显示,控制台也没有任何报错
遇到这种情况,先画一条链路:浏览器 -> Flask -> 模型接口。在 Flask 的/chat路由入口打一条日志,确认请求有没有到达你这边。再在stream_chat的第一行打一条日志,确认模型调用有没有发起。
如果都能打印日志,但前端还是不显示,检查前端的fetch调用是否被打断,或者是不是在读取流之前使用了await response.json()。这个低级错误很常见,我在给新手 review 代码时已经见过不下五次。
7.2 流式输出乱码或数据格式残缺
常见原因是:模型服务返回的非 UTF-8 编码;或者是iter_lines()过程中解码方式不对。我在代码中使用的是line.decode("utf-8"),如果接口返回 GBK 或其他编码,就会乱码。大部分标准接口都是 UTF-8,但如果遇到异常,可以在解码时指定errors="ignore",避免因个别字符解码失败导致整个流中断。
另一个格式不完整的原因是:某些服务为了兼容不同客户端,在流式数据前后加了额外空格或换行。如果你对data:前缀的剔除逻辑不完善,可能会在解析 JSON 时报错。可以在处理前先strip()一下再判断。
7.3 前端收到内容非常慢,一段一段跳得很不流畅
这多半不是模型速度问题,而是链路缓冲问题。排查顺序:先看后端日志中生成器有没有持续 yield;再看 Nginx 是否关闭了缓冲;再看浏览器的网络面板,能否看到持续的 chunk 到达。在这三步中任何一处卡住,你就能定位问题所在。
7.4 请求并发到一定数量后,服务变得卡顿
如果你使用 Flask 开发服务器,这就是典型表现。切换到 gunicorn/gevent 之后会明显改善。如果仍然卡顿,检查模型接口的限流是否已达上限,后端是否被大量连接占满。你还可以为前端增加一个开关,当上一个请求未完成时禁用发送按钮,避免用户连续点发送造成请求堆积。
let isSending = false; async function sendMessage() { if (isSending) return; isSending = true; // ... 原有逻辑 ... isSending = false; }这段小小的防重复提交逻辑,简单但有效。很多用户在问“等待一个回复需要 20 秒,这期间连点三次发送会发生什么?”如果不加锁,服务端会收到三个并发请求,既浪费 token,也可能把对话上下文搞乱。
7.5 完整问题速查表
| 现象 | 大概率原因 | 解决方式 |
|---|---|---|
| 前端一直不显示内容,也没有报错 | 后端请求模型接口失败,但前端未捕获 | 在后端用 try/except 包裹,yield 错误提示 |
| 内容一次性出现,没有流式效果 | Nginx 缓冲开启 / 未使用生成器 | proxy_buffering off/ 检查 yield |
| 控制台报 CORS 错误 | 直接调用非允许的跨域接口 | 改为后端中转,不动前端跨域 |
| 历史消息太乱,模型答非所问 | 消息列表顺序或 role 标识不正确 | 检查消息顺序和 role 字段 |
| 请求超时 | read_timeout太小 | 调大至 60 秒或按需设置 |
| 密钥泄露 | 硬编码在前端代码或仓库 | 移到环境变量,前端不允许持有密钥 |
| 一问一答正常,但连续对话记不住 | 没有发送历史消息 | 保存 messageHistory 并传给后端 |
| 后端日志正常,但浏览器卡死 | 前端未正确读取流,尝试json()等一次性解析 | 改用reader.read() |
8. 向真实业务扩展:从聊天窗口到智能应用
8.1 预留工具调用与业务动作
纯聊天只是第一步。真正让 ChatGPT 在你的应用中产生价值的,是让它和你已有的业务系统联动。比如用户问“帮我查一下本月销售数据”,你希望的不是模型自己编一个数字,而是让模型看到一个可以查数据库的函数,并主动调用它。
这种做法通常被称作“函数调用”或“工具调用”。你需要把工具列表作为参数传给模型接口,说明每个函数的名字、参数与用途。模型在回答时会返回一个结构化的“调用工具意图”,你的后端解析这个意图,执行自己的业务函数,再把真实结果回填给模型,最终生成面向用户的回答。
在 Flask 中实现时,可以把工具列表维护在一个注册表中,后端执行时再通过tools参数传给模型。这个架构不复杂,但会让你的应用从“问答玩具”变成“能办事的助手”。如果后续有时间,我会再写一篇专门的工具调用实战。
8.2 流式过程中的日志与监控
上生产后,日志和监控就是你的眼睛。每个请求进来时打印时间、用户标识、消息长度、模型返回耗时;每次答完一轮后打印总 token 数、首字延迟、整体耗时。这些数据可以帮你判断服务健康度,也能在异常时帮你复盘。
可以给每条请求生成本地 ID,用request.headers中的来源信息或用户的会话 ID 关联日志。最简单的方式是使用 Python 的logging模块,输出到标准输出,再由 Docker 或宿主机日志组件统一收集。数据不必多,但“一次请求一次完整链路日志”必须有。
以下是一个最小但实用的日志示例:
import logging import time logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) @app.route("/chat", methods=["POST"]) def chat(): data = request.get_json() prompt = data.get("prompt", "") user_id = request.headers.get("X-User-Id", "anonymous") start = time.time() logger.info("chat start user=%s prompt_len=%d", user_id, len(prompt)) # ... 调用流式生成 ... logger.info("chat done user=%s cost=%.2fs", user_id, time.time() - start)这种代码没有太多技术含量,但在调线上问题时,没有日志简直寸步难行。千万不要跳过我这一节,觉得多写几行print就行了。logging模块比print强在分级、记录时间戳、支持结构化、不轻易被缓冲吞掉。
8.3 多轮对话的体验优化方向
基础功能跑通后,你还可以打磨一下细节,比如:
- 用户输入时显示“正在输入”的动态效果
- 支持 Markdown 格式的渲染,让代码块、列表看起来更舒服
- 支持一键清空当前对话,重新开始
- 在每条助手消息后面加上“复制”按钮
- 支持停止生成,用户中断后取消流式请求
这些都是可以在一个下午内实现的优化,但它们的共同价值是提高用户对产品的信心。绝大多数人第一次试用 AI 对话功能,都会先被“它居然能实时输出”这一效果吸引,流畅的交互会极大地增强他们对功能可靠性的感知。
我个人在实际项目中的经验是:先跑通“流式 + 多轮记忆 + 基础错误处理”三件套,不要急着加花哨功能。因为这三者是系统的骨架,撑起来之后,剩下的优化都是锦上添花。每一步都亲手在浏览器里测过,才能确保交付到真实用户手里时不翻车。
9. 写在最后:动手跑通一次才是真学会
这篇文章覆盖了从环境搭建、后端接口、前端流式渲染、上下文管理、部署优化到问题排查的完整路径。所有代码都围绕一个核心目标:让你的 Flask 应用拥有一个流式响应的 AI 对话助手。
如果你在本地照着做,遇到最多的一个体验卡点大概率还是“流式效果出不来”。请记住这条排查口诀:先看 Python 生成器有没有执行到yield,再看 Nginx 是否开着proxy_buffering off,最后看前端用的是不是getReader()。这三层都打通,你会发现流式聊天其实并不复杂。
最后再分享一个小技巧:调试阶段,可以先不接真实的模型服务,而是在stream_chat里用一个for循环模拟分块输出。这样你可以在不消耗任何词条配额的情况下,把前后端流式链路完整调通。等确认通了你再切换为真实模型,一次成功,非常稳。