目录标题
- [从一次性返回到流式输出(StreamingResponse 全链路拆解)](#从一次性返回到流式输出(StreamingResponse 全链路拆解))
-
- [1. 为什么一定要做 Streaming?](#1. 为什么一定要做 Streaming?)
- [2. 本次改动的整体结构变化](#2. 本次改动的整体结构变化)
- [3. FastAPI 层:为什么要用 StreamingResponse?](#3. FastAPI 层:为什么要用 StreamingResponse?)
-
- [3.1 import 的变化](#3.1 import 的变化)
- [为什么不用 JSONResponse 了?](#为什么不用 JSONResponse 了?)
- [4. 规则分支也升级为 Streaming(设计一致性)](#4. 规则分支也升级为 Streaming(设计一致性))
-
- [4.1 时间规则(get_time)](#4.1 时间规则(get_time))
- [为什么"简单结果"也用 Streaming?](#为什么“简单结果”也用 Streaming?)
- [4.2 数学规则 Streaming 化](#4.2 数学规则 Streaming 化)
- [5. 真正的核心:LLM Streaming 全链路](#5. 真正的核心:LLM Streaming 全链路)
- [6. MyGeminiLLM:底层流式封装](#6. MyGeminiLLM:底层流式封装)
-
- [6.1 新增 generate_stream](#6.1 新增 generate_stream)
- [与 generate 的核心差别](#与 generate 的核心差别)
- [6.2 逐 chunk 读取](#6.2 逐 chunk 读取)
- [7. MyCustomGeminiLLM:Streaming + System Prompt](#7. MyCustomGeminiLLM:Streaming + System Prompt)
-
- [System Prompt 只做一件事](#System Prompt 只做一件事)
- [8. FastAPI /ask:Streaming + Session 的结合(难点)](#8. FastAPI /ask:Streaming + Session 的结合(难点))
-
- [8.1 answer_stream 生成器](#8.1 answer_stream 生成器)
- [8.2 Streaming 结束后再写入 Session](#8.2 Streaming 结束后再写入 Session)
-
- [为什么不能边生成边写 history?](#为什么不能边生成边写 history?)
- [9. 最终返回 StreamingResponse](#9. 最终返回 StreamingResponse)
-
- [为什么是 text/plain?](#为什么是 text/plain?)
- [10. 现在这个系统,已经进化成什么?](#10. 现在这个系统,已经进化成什么?)
- [11. 常见问题与边界说明](#11. 常见问题与边界说明)
-
- [11.1 Streaming 并不等于异步](#11.1 Streaming 并不等于异步)
- [11.2 前端需要配合处理流](#11.2 前端需要配合处理流)
- [11.3 内存问题](#11.3 内存问题)
- [12. 与 ChatGPT / OpenAI API 的对齐程度](#12. 与 ChatGPT / OpenAI API 的对齐程度)
- [13. 总结](#13. 总结)
- [[附]: diff 涉及的 **3 个文件**(`app.py` / `my_custom_llm.py` / `my_llm.py`),分别拆解](#[附]: diff 涉及的 3 个文件(
app.py/my_custom_llm.py/my_llm.py),分别拆解) - 1) app.py:从 JSONResponse 改为 StreamingResponse(规则分支 + LLM 分支全统一) app.py:从 JSONResponse 改为 StreamingResponse(规则分支 + LLM 分支全统一))
-
- [1.1 import 变化:为什么删 JSONResponse、加 StreamingResponse?](#1.1 import 变化:为什么删 JSONResponse、加 StreamingResponse?)
- [1.2 /ask:规则 1(时间/日期)由 JSON 改为流式](#1.2 /ask:规则 1(时间/日期)由 JSON 改为流式)
-
- 逐行解释
- [为什么 media_type 用 text/plain?](#为什么 media_type 用 text/plain?)
- [1.3 /ask:规则 2(加法表达式)由 JSON 改为流式](#1.3 /ask:规则 2(加法表达式)由 JSON 改为流式)
- [1.4 /ask:LLM 分支真正的改动点------generate → generate_stream + 结果收集](#1.4 /ask:LLM 分支真正的改动点——generate → generate_stream + 结果收集)
- 2) my_custom_llm.py:新增 generate_stream(系统提示词 + 流式复用父类) my_custom_llm.py:新增 generate_stream(系统提示词 + 流式复用父类))
-
- [2.1 为什么必须在 MyCustomGeminiLLM 也加 generate_stream?](#2.1 为什么必须在 MyCustomGeminiLLM 也加 generate_stream?)
- [2.2 prompt 拼接的细节:为什么这里会出现"用户/助手"?](#2.2 prompt 拼接的细节:为什么这里会出现“用户/助手”?)
- 3) my_llm.py:新增 generate_stream(与 generate 同一套 generation_config) my_llm.py:新增 generate_stream(与 generate 同一套 generation_config))
-
- [3.1 generation_config:保持与非流式一致](#3.1 generation_config:保持与非流式一致)
-
- [为什么要复制一份而不是复用 generate 的变量?](#为什么要复制一份而不是复用 generate 的变量?)
- [3.2 调用 Gemini SDK 的关键:stream=True](#3.2 调用 Gemini SDK 的关键:stream=True)
- [3.3 逐块产出:只要 chunk 有 text 就 yield](#3.3 逐块产出:只要 chunk 有 text 就 yield)
-
- [为什么用 getattr?](#为什么用 getattr?)
- 4) 三文件组合后的"端到端链路"总结 三文件组合后的“端到端链路”总结)

python
这是另一个 任务 diff --git a/app.py b/app.py
index ef91a3fd2b58c87405c28913431e4aad80443bdf..db94e390115b7bd23c7a3ae34547ec84389ef686 100644
--- a/app.py
+++ b/app.py
@@ -1,31 +1,30 @@
-from fastapi import FastAPI
-from fastapi.responses import HTMLResponse, JSONResponse
-from fastapi.middleware.cors import CORSMiddleware
-from typing import Optional
-import re
-from typing import Dict, List, Optional, Tuple
+from fastapi import FastAPI
+from fastapi.responses import HTMLResponse, StreamingResponse
+from fastapi.middleware.cors import CORSMiddleware
+import re
+from typing import Dict, List, Optional, Tuple
from pydantic import BaseModel
from my_llm import MyGeminiLLM
from my_custom_llm import MyCustomGeminiLLM
from tools import add, get_time
app = FastAPI()
# 会话历史字典:key 是 session_id,value 是 [(question, answer), ...]
# 用于在多轮对话中保留上下文,便于拼接成连续对话的 prompt
sessions: Dict[str, List[Tuple[str, str]]] = {}
# 允许你的 GitHub Pages 调用(/ai 仍属于同一域名)
app.add_middleware(
CORSMiddleware,
allow_origins=["https://iehmltym.github.io"],
allow_origin_regex=r"https://iehmltym\.github\.io$",
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
expose_headers=["*"],
max_age=86400,
)
# 全局默认的 LLM 实例(不带 system prompt 的情况使用它)
@@ -43,68 +42,79 @@ class QuestionRequest(BaseModel):
# 为空时表示单轮请求,不记录历史
session_id: Optional[str] = None
@app.get("/health")
def health():
return {"ok": True}
@app.get("/", response_class=HTMLResponse)
def index():
# 不依赖 templates 文件,避免 Render 上找不到文件导致异常
return """
<!doctype html>
<html lang="zh">
<head><meta charset="utf-8"><title>MyAgent API</title></head>
<body>
<h2>MyAgent API is running</h2>
<p>Health: <a href="/health">/health</a></p>
<p>Use <code>POST /ask</code> with JSON: {"question":"..."}</p>
</body>
</html>
"""
-@app.post("/ask")
-def ask_question(req: QuestionRequest):
- # 取出用户输入的问题文本,便于后续处理
- question = req.question
- # 规则 1:如果问题中出现"现在几点"或"今天日期",直接返回当前时间
- if "现在几点" in question or "今天日期" in question:
- return JSONResponse({"answer": get_time()})
+@app.post("/ask")
+def ask_question(req: QuestionRequest):
+ # 取出用户输入的问题文本,便于后续处理
+ question = req.question
+ # 规则 1:如果问题中出现"现在几点"或"今天日期",直接返回当前时间
+ if "现在几点" in question or "今天日期" in question:
+ def time_stream():
+ yield get_time()
+
+ return StreamingResponse(time_stream(), media_type="text/plain; charset=utf-8")
# 规则 2:如果问题符合 "a+b=?" 形式,解析出 a、b 并计算
# 说明:下面这个正则允许空格和小数,比如 " 1 + 2 = ? "
- math_match = re.fullmatch(r"\s*(\d+(?:\.\d+)?)\s*\+\s*(\d+(?:\.\d+)?)\s*=\s*\?\s*", question)
- if math_match:
- # 正则分组 1 和 2 分别是 a、b 的文本形式
- left = float(math_match.group(1))
- right = float(math_match.group(2))
- # 计算完成后直接返回,避免走 LLM
- return JSONResponse({"answer": str(add(left, right))})
+ math_match = re.fullmatch(r"\s*(\d+(?:\.\d+)?)\s*\+\s*(\d+(?:\.\d+)?)\s*=\s*\?\s*", question)
+ if math_match:
+ # 正则分组 1 和 2 分别是 a、b 的文本形式
+ left = float(math_match.group(1))
+ right = float(math_match.group(2))
+ # 计算完成后直接返回,避免走 LLM
+ def math_stream():
+ yield str(add(left, right))
+
+ return StreamingResponse(math_stream(), media_type="text/plain; charset=utf-8")
# 如果前端传了 system_prompt,就用它;
# 否则使用默认的可爱语气 prompt。
system_prompt = req.system_prompt or DEFAULT_CUTE_SYSTEM_PROMPT
# 始终用自定义 LLM 包装器注入 system prompt,让回答保持可爱风格
active_llm = MyCustomGeminiLLM(prefix=system_prompt)
# 规范化 session_id(去掉首尾空白),避免同一会话被当成多个 key
session_id = req.session_id.strip() if req.session_id else None
# 获取该会话的历史;若不存在则初始化为空列表
# 未提供 session_id 时视为单轮对话,不读取/写入历史
history = sessions.setdefault(session_id, []) if session_id else []
# 将历史记录拼成连续对话的 prompt,格式如:
# 用户:... \n 助手:... \n 用户:... \n 助手:...
history_prompt = "".join(
f"用户:{question}\n助手:{response}\n" for question, response in history
)
# 拼接本次问题,提示模型继续回复助手内容
prompt = f"{history_prompt}用户:{req.question}\n助手:"
# 调用模型生成答案,max_output_tokens 适当提高以避免回答被截断
- answer = active_llm.generate(prompt, max_output_tokens=2048) # 避免只返回半句
- if session_id:
- # 仅当 session_id 有效时才记录历史,避免无意义的全局堆积
- history.append((req.question, answer))
- # 返回格式保持 {"answer": ...},确保前端兼容
- return JSONResponse({"answer": answer})
+ def answer_stream():
+ answer_parts = []
+ for chunk in active_llm.generate_stream(prompt, max_output_tokens=2048):
+ answer_parts.append(chunk)
+ yield chunk
+ if session_id:
+ answer = "".join(answer_parts)
+ # 仅当 session_id 有效时才记录历史,避免无意义的全局堆积
+ history.append((req.question, answer))
+
+ return StreamingResponse(answer_stream(), media_type="text/plain; charset=utf-8")
my_custom_llm.py
+10
-0
@@ -2,25 +2,35 @@ from typing import Optional, Any # 类型提示
from my_llm import MyGeminiLLM, GeminiConfig # 复用基础封装和配置类
class MyCustomGeminiLLM(MyGeminiLLM):
"""
自定义 LLM:在基础 LLM 上增加"固定前缀"
这个前缀相当于:系统提示词 / 角色设定 / 固定上下文
"""
def __init__(self, prefix: str, config: Optional[GeminiConfig] = None):
# 把 prefix 存起来,strip 去掉首尾空白
self.prefix = prefix.strip()
# 调用父类初始化:加载 key、选模型、创建 model
super().__init__(config=config)
def generate(self, prompt: str, **kwargs: Any) -> str:
# 如果 prefix 非空,把 prefix + 用户输入拼成最终 prompt
if self.prefix:
final_prompt = f"{self.prefix}\n\n用户:{prompt}\n助手:"
else:
final_prompt = prompt
# 调用父类 generate 实际请求 Gemini
return super().generate(final_prompt, **kwargs)
def generate_stream(self, prompt: str, **kwargs: Any):
# 如果 prefix 非空,把 prefix + 用户输入拼成最终 prompt
if self.prefix:
final_prompt = f"{self.prefix}\n\n用户:{prompt}\n助手:"
else:
final_prompt = prompt
# 调用父类 generate_stream 实际请求 Gemini
return super().generate_stream(final_prompt, **kwargs) my_llm.py
+26
-0
@@ -76,25 +76,51 @@ class MyGeminiLLM:
生成文本的统一入口。
:param prompt: 用户输入文本
:param kwargs: 允许调用者覆盖生成参数,例如 temperature=0.2
:return: Gemini 返回的文本
"""
# generation_config:本次生成的参数
# kwargs 优先级更高:外部传了就覆盖默认 config
generation_config: Dict[str, Any] = {
"temperature": kwargs.get("temperature", self.config.temperature),
"max_output_tokens": kwargs.get("max_output_tokens", self.config.max_output_tokens),
"top_p": kwargs.get("top_p", self.config.top_p),
"top_k": kwargs.get("top_k", self.config.top_k),
}
# 调用 Gemini 的生成接口:
# - prompt:你要问的问题
# - generation_config:控制输出风格/长度
resp = self.model.generate_content(
prompt,
generation_config=generation_config
)
# resp.text:SDK 帮你拼好的纯文本输出(若为空则返回空字符串)
return resp.text or ""
def generate_stream(self, prompt: str, **kwargs: Any):
"""
流式生成文本的统一入口。
:param prompt: 用户输入文本
:param kwargs: 允许调用者覆盖生成参数,例如 temperature=0.2
:return: 逐块产出的文本片段
"""
generation_config: Dict[str, Any] = {
"temperature": kwargs.get("temperature", self.config.temperature),
"max_output_tokens": kwargs.get("max_output_tokens", self.config.max_output_tokens),
"top_p": kwargs.get("top_p", self.config.top_p),
"top_k": kwargs.get("top_k", self.config.top_k),
}
stream = self.model.generate_content(
prompt,
generation_config=generation_config,
stream=True,
)
for chunk in stream:
text = getattr(chunk, "text", None)
if text:
yield text
从一次性返回到流式输出(StreamingResponse 全链路拆解)
本文目标:
把一个「一次性返回 answer 的 LLM API」,升级成
"像 ChatGPT 一样边生成、边返回"的流式接口
这是从「能用」到「体验专业」的关键一步。
1. 为什么一定要做 Streaming?
在之前的版本中,流程是这样的:
用户请求
↓
后端等待 LLM 全部生成完成
↓
一次性返回完整 answer
问题在于:
- ❌ 用户等待时间长(几秒 ~ 十几秒)
- ❌ 前端没有"正在思考"的感觉
- ❌ 长回答时,体验很差
而 ChatGPT 的体验是:
Thinking...
你 → 看 → 字一个一个出来
👉 Streaming(流式输出)就是为了解决这个问题。
2. 本次改动的整体结构变化
一句话总结:
所有返回 answer 的地方,统一升级为 StreamingResponse
涉及三个层级:
Gemini SDK (stream=True)
↓
MyGeminiLLM.generate_stream()
↓
MyCustomGeminiLLM.generate_stream()
↓
FastAPI StreamingResponse
↓
前端逐字符 / 逐 chunk 接收
3. FastAPI 层:为什么要用 StreamingResponse?
3.1 import 的变化
diff
-from fastapi.responses import HTMLResponse, JSONResponse
+from fastapi.responses import HTMLResponse, StreamingResponse
为什么不用 JSONResponse 了?
-
JSONResponse 需要一次性构造完整内容
-
StreamingResponse 支持:
- generator / iterator
- 边 yield 边发送
👉 StreamingResponse 是 FastAPI 实现 SSE / 流式 API 的核心工具。
4. 规则分支也升级为 Streaming(设计一致性)
4.1 时间规则(get_time)
python
if "现在几点" in question or "今天日期" in question:
def time_stream():
yield get_time()
return StreamingResponse(
time_stream(),
media_type="text/plain; charset=utf-8"
)
为什么"简单结果"也用 Streaming?
这是一个非常专业的设计点:
-
接口返回类型保持一致
-
前端不用区分:
- 这是规则返回
- 还是 LLM 返回
-
所有情况都"按流读取"
👉 统一抽象 > 局部性能优化
4.2 数学规则 Streaming 化
python
def math_stream():
yield str(add(left, right))
即使只有一个 chunk:
- 仍然走 StreamingResponse
- 为未来扩展(复杂计算、步骤输出)留空间
5. 真正的核心:LLM Streaming 全链路
这是这次改动的技术核心。
6. MyGeminiLLM:底层流式封装
6.1 新增 generate_stream
python
def generate_stream(self, prompt: str, **kwargs: Any):
与 generate 的核心差别
python
stream = self.model.generate_content(
prompt,
generation_config=generation_config,
stream=True,
)
关键点只有一个:
stream=True
这会让 Gemini SDK 返回一个 可迭代对象。
6.2 逐 chunk 读取
python
for chunk in stream:
text = getattr(chunk, "text", None)
if text:
yield text
这一段非常重要
-
每个
chunk可能只包含:- 一个词
- 一段话
-
yield text:- 不缓存
- 立即往上游返回
👉 这是"流"的本质。
7. MyCustomGeminiLLM:Streaming + System Prompt
python
def generate_stream(self, prompt: str, **kwargs: Any):
System Prompt 只做一件事
python
final_prompt = f"{self.prefix}\n\n用户:{prompt}\n助手:"
然后:
python
return super().generate_stream(final_prompt, **kwargs)
设计亮点
- ❌ 不重复写 streaming 逻辑
- ✅ 复用父类
- ✅ prefix 与 streaming 完全解耦
这是非常干净的继承设计。
8. FastAPI /ask:Streaming + Session 的结合(难点)
这是整个 diff 最有价值的部分。
8.1 answer_stream 生成器
python
def answer_stream():
answer_parts = []
for chunk in active_llm.generate_stream(prompt, max_output_tokens=2048):
answer_parts.append(chunk)
yield chunk
这里做了两件事(非常关键)
-
yield chunk- 实时返回给前端
-
answer_parts.append(chunk)- 在内存中完整收集答案
👉 这是为了 "Streaming + 会话历史" 同时成立。
8.2 Streaming 结束后再写入 Session
python
if session_id:
answer = "".join(answer_parts)
history.append((req.question, answer))
为什么不能边生成边写 history?
- history 需要 完整 answer
- streaming 过程中答案是不完整的
- 提前写会污染上下文
👉 这是 Streaming 系统里非常容易踩的坑。
9. 最终返回 StreamingResponse
python
return StreamingResponse(
answer_stream(),
media_type="text/plain; charset=utf-8"
)
为什么是 text/plain?
- 前端逐字符接收更简单
- 不需要等 JSON 结束符
- 更接近 ChatGPT / SSE 行为
👉 如果未来要做 SSE,可升级为 text/event-stream。
10. 现在这个系统,已经进化成什么?
当前的系统,已经具备以下能力:
| 能力 | 是否具备 |
|---|---|
| 单轮问答 | ✅ |
| 多轮对话(Session) | ✅ |
| 规则优先 | ✅ |
| Tool 调用 | ✅ |
| LLM 兜底 | ✅ |
| 流式输出 | ✅ |
👉 这已经是一个"完整 Agent 服务雏形"了。
11. 常见问题与边界说明
11.1 Streaming 并不等于异步
- StreamingResponse ≠ async
- 当前代码仍是同步 generator
- 优点:简单、稳定
11.2 前端需要配合处理流
- fetch + ReadableStream
- WebSocket / EventSource(可选)
11.3 内存问题
-
长对话:
- history 增长
- prompt 变长
-
streaming 本身不会解决 prompt 膨胀
👉 后续需要:
- 历史裁剪
- 自动摘要
12. 与 ChatGPT / OpenAI API 的对齐程度
现在的实现,本质是:
"手写版 Chat Completions Streaming"
如果能完全理解这一版:
- 再看 OpenAI 的
delta/choices - 再看 LangChain Streaming Callback
都会非常清晰。
13. 总结
这次升级,把项目从:
"能用的 LLM API"
真正推到了:
"用户体验接近 ChatGPT 的 Agent 后端"
[附]: diff 涉及的 3 个文件 (app.py / my_custom_llm.py / my_llm.py),分别拆解
1) app.py:从 JSONResponse 改为 StreamingResponse(规则分支 + LLM 分支全统一)
1.1 import 变化:为什么删 JSONResponse、加 StreamingResponse?
diff
-from fastapi.responses import HTMLResponse, JSONResponse
+from fastapi.responses import HTMLResponse, StreamingResponse
JSONResponse适合"一次性返回完整 JSON"。StreamingResponse适合"边生成边返回"的场景:它接受一个 可迭代对象/生成器 ,每次yield一段就立即推送给客户端。- 这里的设计是:所有 /ask 的返回都统一成流式(包括规则命中),这样前端处理逻辑一致。
关键设计点:统一接口形态。即使是"时间/加法"这种秒回场景,也走 StreamingResponse,前端不用分支判断"这是 JSON 还是流"。
1.2 /ask:规则 1(时间/日期)由 JSON 改为流式
旧:直接 JSON 返回:
python
return JSONResponse({"answer": get_time()})
新:定义一个生成器,并用 StreamingResponse 返回:
python
def time_stream():
yield get_time()
return StreamingResponse(time_stream(), media_type="text/plain; charset=utf-8")
逐行解释
-
def time_stream(): ... yield ...- 这是一个 生成器函数(generator)。
yield get_time()表示:产生一个字符串片段并结束(这里就 1 个片段)。
-
StreamingResponse(time_stream(), ...)- StreamingResponse 接受一个迭代器(这里是生成器对象)。
- 客户端会收到这一个片段并完成连接(因为生成器结束了)。
为什么 media_type 用 text/plain?
-
流式 JSON 不是不行,但会复杂:
- JSON 必须整体结构闭合,流式中间阶段不是合法 JSON。
-
text/plain更适合"逐块输出文本",前端读取简单。 -
加上
charset=utf-8可以避免中文乱码。
1.3 /ask:规则 2(加法表达式)由 JSON 改为流式
新代码核心:
python
def math_stream():
yield str(add(left, right))
return StreamingResponse(math_stream(), media_type="text/plain; charset=utf-8")
这段正则仍是"严格匹配"的优点
python
re.fullmatch(r"\s*(\d+(?:\.\d+)?)\s*\+\s*(\d+(?:\.\d+)?)\s*=\s*\?\s*", question)
fullmatch:必须整行完全匹配,避免误判。- 支持空格、小数。
- 仍然是 规则优先:命中就直接返回,不走 LLM。
1.4 /ask:LLM 分支真正的改动点------generate → generate_stream + 结果收集
原来是一次性生成:
python
answer = active_llm.generate(prompt, max_output_tokens=2048)
return JSONResponse({"answer": answer})
现在变成:
python
def answer_stream():
answer_parts = []
for chunk in active_llm.generate_stream(prompt, max_output_tokens=2048):
answer_parts.append(chunk)
yield chunk
if session_id:
answer = "".join(answer_parts)
history.append((req.question, answer))
return StreamingResponse(answer_stream(), media_type="text/plain; charset=utf-8")
逐行拆解(这是全链路的核心)
def answer_stream():
- 生成器:负责把模型产生的 chunk 逐块
yield给前端。
answer_parts = []
- 这里非常关键:既要流式输出,也要把最终完整 answer 存进 session history。
- 因为 history 需要完整文本,而 streaming 是分块来的,所以需要缓存所有 chunk。
for chunk in active_llm.generate_stream(...):
- 从 LLM 层拿到"流式 chunk"迭代器。
- 每个 chunk 是一段文本(可能很短,甚至一个词)。
answer_parts.append(chunk)
- 把 chunk 收集起来,后面可以
join得到完整 answer。
yield chunk
- 把 chunk 立即发送给客户端,实现"打字机效果"。
if session_id: answer="".join(answer_parts); history.append((question, answer))
-
关键点:等流式全部结束再写入 history。
-
这是正确做法,因为:
- 对话历史应该是完整问答对
- 如果边生成边写入,会产生不完整/重复/污染上下文的问题
StreamingResponse(answer_stream(), media_type="text/plain; charset=utf-8")
- 统一返回流式文本。
重要边界
- 现在的 StreamingResponse 是"文本流",不是 SSE,也不是 WebSocket。
- 前端需要用
ReadableStream来逐块读取响应体。 - session history 仍然存内存,存在增长风险(已在前面的版本提到过)。
2) my_custom_llm.py:新增 generate_stream(系统提示词 + 流式复用父类)
新增了大约 10 行,核心是:
python
def generate_stream(self, prompt: str, **kwargs: Any):
if self.prefix:
final_prompt = f"{self.prefix}\n\n用户:{prompt}\n助手:"
else:
final_prompt = prompt
return super().generate_stream(final_prompt, **kwargs)
2.1 为什么必须在 MyCustomGeminiLLM 也加 generate_stream?
因为在 app.py 里调用的是:
python
active_llm.generate_stream(...)
而 active_llm 是 MyCustomGeminiLLM 实例。如果子类没有实现 generate_stream,会发生两种情况:
- 如果父类有
generate_stream,也能调用到,但不会注入 prefix(系统提示词失效)。 - 或者原本的父类没有
generate_stream,会直接报错。
现在的实现确保:
- 同步
generate():支持 prefix - 新增
generate_stream():也支持 prefix - 两者行为一致(非常关键:避免"流式时忘了加系统提示词")
2.2 prompt 拼接的细节:为什么这里会出现"用户/助手"?
python
final_prompt = f"{self.prefix}\n\n用户:{prompt}\n助手:"
实际上做了两层结构:
self.prefix:系统提示词(角色/规则)用户:{prompt}\n助手::把 prompt 包装成对话格式,引导模型以"助手口吻"继续输出
这会让模型更稳定地输出"回答内容",而不是跑题或重复提问。
需要注意:在 app.py 里已经把对话历史组织成 "用户/助手" 格式了;这里再包一层,可能导致 prompt 内出现嵌套的"用户/助手"。是否会影响效果,取决于上游
prompt的内容结构。如果发现模型重复"用户:...",可以考虑让 app.py 传入"纯用户问题+历史",而 my_custom_llm.py 只负责 prefix;或者反过来,统一在一个地方负责"用户/助手格式"。
(这条属于"高级优化建议",不影响当前实现正确性。)
3) my_llm.py:新增 generate_stream(与 generate 同一套 generation_config)
新增了约 26 行,核心是:
3.1 generation_config:保持与非流式一致
python
generation_config: Dict[str, Any] = {
"temperature": kwargs.get("temperature", self.config.temperature),
"max_output_tokens": kwargs.get("max_output_tokens", self.config.max_output_tokens),
"top_p": kwargs.get("top_p", self.config.top_p),
"top_k": kwargs.get("top_k", self.config.top_k),
}
为什么要复制一份而不是复用 generate 的变量?
从工程角度看有两个选择:
- A:抽一个私有方法
_build_generation_config(kwargs),generate 和 generate_stream 共用(更优雅) - B:先复制一份,保证逻辑清晰(现在就是这样)
现在的写法优点是:新手可读性强,改动风险小。
3.2 调用 Gemini SDK 的关键:stream=True
python
stream = self.model.generate_content(
prompt,
generation_config=generation_config,
stream=True,
)
这是流式的本质:
- 非流式:
resp = generate_content(...); return resp.text - 流式:
stream = generate_content(..., stream=True); for chunk in stream: yield chunk.text
3.3 逐块产出:只要 chunk 有 text 就 yield
python
for chunk in stream:
text = getattr(chunk, "text", None)
if text:
yield text
为什么用 getattr?
- 防御性写法:避免 SDK 对象结构变化或某些 chunk 没有 text 字段导致抛异常。
if text::过滤空 chunk,避免前端收到一堆空字符串。
4) 三文件组合后的"端到端链路"总结
现在的链路是:
-
app.py接到请求,优先规则短路(时间/加法)。 -
若走 LLM:
- 构造多轮 prompt(历史 + 本轮问题)
- 调用
MyCustomGeminiLLM.generate_stream(...)
-
my_custom_llm.py- 注入
prefix(system prompt) - 把最终 prompt 交给父类流式方法
- 注入
-
my_llm.pystream=True调用 Gemini SDKyield每个 chunk 的 text
-
app.py- 一边
yield给前端 - 一边收集 answer_parts
- 流式结束后把完整 answer 写入 session history
- 一边
这就实现了:
- 类 ChatGPT 的流式输出体验
- 同时保留多轮历史(用于下一轮)