【AI】09AI Agent LLM → Streaming → Session 记录 的完整链路

目录标题

  • [从一次性返回到流式输出(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
这里做了两件事(非常关键)
  1. yield chunk

    • 实时返回给前端
  2. 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")

逐行拆解(这是全链路的核心)

  1. def answer_stream():
  • 生成器:负责把模型产生的 chunk 逐块 yield 给前端。
  1. answer_parts = []
  • 这里非常关键:既要流式输出,也要把最终完整 answer 存进 session history
  • 因为 history 需要完整文本,而 streaming 是分块来的,所以需要缓存所有 chunk。
  1. for chunk in active_llm.generate_stream(...):
  • 从 LLM 层拿到"流式 chunk"迭代器。
  • 每个 chunk 是一段文本(可能很短,甚至一个词)。
  1. answer_parts.append(chunk)
  • 把 chunk 收集起来,后面可以 join 得到完整 answer。
  1. yield chunk
  • 把 chunk 立即发送给客户端,实现"打字机效果"。
  1. if session_id: answer="".join(answer_parts); history.append((question, answer))
  • 关键点:等流式全部结束再写入 history。

  • 这是正确做法,因为:

    • 对话历史应该是完整问答对
    • 如果边生成边写入,会产生不完整/重复/污染上下文的问题
  1. 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_llmMyCustomGeminiLLM 实例。如果子类没有实现 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) 三文件组合后的"端到端链路"总结

现在的链路是:

  1. app.py 接到请求,优先规则短路(时间/加法)。

  2. 若走 LLM:

    • 构造多轮 prompt(历史 + 本轮问题)
    • 调用 MyCustomGeminiLLM.generate_stream(...)
  3. my_custom_llm.py

    • 注入 prefix(system prompt)
    • 把最终 prompt 交给父类流式方法
  4. my_llm.py

    • stream=True 调用 Gemini SDK
    • yield 每个 chunk 的 text
  5. app.py

    • 一边 yield 给前端
    • 一边收集 answer_parts
    • 流式结束后把完整 answer 写入 session history

这就实现了:

  • 类 ChatGPT 的流式输出体验
  • 同时保留多轮历史(用于下一轮)

相关推荐
水如烟2 小时前
孤能子视角:“数学“,哥德尔边界动力学分析
人工智能
摸鱼仙人~2 小时前
Agent 意图识别:从传统 NLU 到 LLM 驱动的范式变革
人工智能·深度学习
Narrastory2 小时前
给数据“做减法”:PCA与UMAP详解与代码实现
人工智能·数据分析
kevin_kang2 小时前
技术选型背后的思考:为什么选择Next.js + FastAPI + LangChain
人工智能
了了侠2 小时前
也论三个数字化与Palantir本体论
人工智能·数字化转型·业务架构·业务建模·本体论·palantir
kevin_kang2 小时前
三层架构设计:前端+业务+AI层的职责划分
人工智能
yanghuashuiyue2 小时前
deepseek本地部署
人工智能·自然语言处理·deepseek
不惑_2 小时前
通俗理解神经网络的反向传播
人工智能·深度学习·神经网络