agent学习——流式响应与文本切分

大东西别一次性处理,切成可控的小块,再逐块流转。这句话能同时解释两件看着不搭界的事:一个是分块读文件 (下载大视频、LLM 逐字输出),一个是分块切文本(RAG 入库前的预处理)。前者解决的是「内存装不下、用户等得久」,后者解决的是「整篇塞不进窗口、语义被稀释」。本文把这两棒拆开讲透,并给出工程上已经被反复验证的默认配置。


一、流式响应:大文件别一次塞进内存

1.1 为什么必须流式

三个独立的理由,缺一个就够你上流式:

  • 内存有限 :一个 500MB 的视频,用 f.read() 一把读进内存再返回,进程直接 OOM 崩掉。流式永远只在内存里留一小块。
  • 首字节时间(TTFB):用户要等服务器把整份文件读完、算完,才能收到第一个字节。大文件下这种「白等」是体验杀手。
  • 背压(Backpressure)天然成立:流式不是「服务器拼命推、客户端拼命收」,而是客户端按自己的消费速度来取。传输层就绪了,生成器才产出下一块------这种天然的流速匹配,是一次性返回根本给不了的。

类比最直观:看视频是边下边播,不是等整部下完再从头看。文件下载接口同理,该边读边发,不该读完再发。

1.2 生成器 yield 到底改变了什么

普通函数 return 是「攒齐了,一次性交差」;生成器 yield 是「走走停停,产出一份、让调用方处理一份,自己挂起等下次」。关键区别在惰性(lazy)------它不积累全部结果,只在被索取时才算下一份。

python 复制代码
# app/api/routes/files.py(流式下载最小实现,示意)
def read_chunk(file_path: str, chunk_size: int = 64 * 1024):
    """分块读取文件,每次只吐 chunk_size 字节(默认 64KB)"""
    with open(file_path, "rb") as f:
        while True:
            data = f.read(chunk_size)
            if not data:
                break
            yield data          # 内存里永远只有这一小块

循环里每次只读 64KB,yield 出去。无论文件多大,进程占用的内存都恒定在这一个 chunk 的量级上。

1.3 StreamingResponse 怎么接住生成器

StreamingResponse 接收的「流」可以是普通生成器,也可以是 async 生成器。这里有一个被多数教程漏掉的关键点:当你传一个普通(同步)生成器时,Starlette 会把它丢进线程池(iterate_in_threadpool)去跑,而不是阻塞主事件循环 。所以即便你用了阻塞的 open() + f.read(),FastAPI 依然能异步对外服务------它在背后帮你做了线程隔离。

python 复制代码
# app/api/routes/files.py(端点与安全防护,示意)
@router.get("/download/{filename}")
def download(filename: str):
    path = os.path.join(UPLOAD_DIR, os.path.basename(filename))  # ① 路径穿越防护
    if not os.path.exists(path):
        raise HTTPException(status_code=404, detail="文件不存在")
    return StreamingResponse(
        read_chunk(path),
        media_type="application/octet-stream",
        headers={"Content-Disposition": f"attachment; filename={filename}"},
    )

几个工程要点:

  • ① 路径穿越防护os.path.basename(filename)../../etc/passwd 这类恶意路径剥成纯文件名,防止读系统文件。这是流式下载接口的安全底线,丢不得。
  • chunk 大小:默认 64KB 对普通接口足够;纯大文件下载可提到 1MB,减少系统调用次数。
  • Content-Dispositionattachment 触发浏览器下载并指定文件名;换成 inline 则直接在页面内预览(如播放 mp4)。配合正确的 media_type(MIME)效果更佳。
  • 真·异步 :如果你追求极致的非阻塞 I/O,用 aiofilesasync 生成器,避免任何线程切换开销。普通场景用同步生成器 + 线程池足矣。

1.4 流式不止于下载:LLM 的逐字输出

同一个心智的第二张脸是 LLM 流式输出 ------token 一个一个往外蹦,前端边收边渲染,用户看到的是「打字机效果」。底层同样是生成器 + StreamingResponse(常以 SSE/text/event-stream 承载)。

现象 真相
明明用了 StreamingResponse,客户端的进度条还是卡住不动 反代 / 负载均衡器默认会缓冲响应,把好不容易拆好的块又攒成大块才发;需要关掉 buffering 或启用 chunked transfer
文本流比二进制流慢很多 没开 GZip;文本型流启用 GZipMiddlewareminimum_size 设小一点)能显著压缩体积
小 JSON 接口要不要也流式 不必。小响应直接 return 普通响应体,流式反而增加复杂度,得不偿失

小结 :流式 = 生成器分块产出 + 响应逐块发送。什么时候必须用? 大文件下载、LLM 逐字输出这两类。它的核心价值是「恒定内存 + 更早的首字节 + 天然背压」。实现上普通生成器就够,Starlette 会替你管线程;只有瓶颈在 I/O 时才上 aiofiles


二、文本切分:RAG 流水线的第一棒

2.1 为什么不能把整份文档直接做 Embedding

两个原因,第二个更隐蔽:

  • 上下文窗口限制:一万字的文档塞不进 2000 字的窗口,单块 Embedding 直接被截断或稀释。
  • 语义稀释 :整篇一次 Embedding,等于把一桌菜打成一杯糊糊。检索时「数据库经验」这种关键信号被平均掉,哪块都沾点、哪块都不准。

正确打法是先切分,每块独立算向量,提问时只召回最相关的几块拼进 Prompt。这是 RAG 质量的最大杠杆 ------业内共识是工程优先级 分块策略 > Embedding 模型选择 > 向量数据库选型。很多团队在向量库上纠结半天,结果分块一塌糊涂,方向跑偏了。

2.2 核心矛盾:切大还是切小

切太大(如 2048 token) 切太小(如 128 token)
多主题混一块,语义被稀释 一句完整的话被腰斩,上下文丢失
检索「啥都能命中一点」但精准度差 LLM 拿到碎片答不出完整答案
浪费 token 和上下文窗口 检索噪音多,召回碎片化

目标是在「精准」和「上下文完整」之间找到甜点。

2.3 chunk_size 与 chunk_overlap 怎么定

经验区间(来自 Chroma、NVIDIA 等多份基准):

  • chunk_size :256--1024 token。通用文档默认 400--512 token;FAQ / 短问答 256--512;长文(论文、技术手册)512--1024。
  • chunk_overlap10%--20% 的 chunk_size。Chroma 的 472-query 基准里,「递归切分 512 token + 10% overlap」拿到 88.5% recall,和最贵的 LLM 语义切分只差 3 个点,且零额外成本------这是有数据支撑的默认起点。

token 计数比字符计数更准 :用 tiktokencl100k_baselength_function,而不是 len()。原因是 token 和字符不是 1:1,且非英文差异巨大------同一句话,土耳其语/日语比英文多 2.7 倍 token,阿拉伯语多 3.9 倍(中文同理)。用字符数切,非英文块的语义量会被悄悄压薄。

overlap 的类比:像全景照片拼接,相邻两张要重叠一部分,否则接缝处正好有个人被劈成两半------上半身在左图、下半身在右图,哪张都认不出他。文本同理,重叠让边界内容两块都有,检索时任一块都能带出完整语义。

2.4 切分策略全家福

别只盯着「按字符滑窗」。生产里按成熟度从低到高有一整排打法:

策略 做法 适用 代价
固定大小 每 N 字符/token 一刀切,可选 overlap 快速基线 可能从句子中间切开
递归字符切分 \n\n\n. 优先级找边界 生产默认,通用文档 极低
语义切分 给每句算 Embedding,相似度骤降处切 结构混乱 / 主题杂的语料 入库时要额外跑 Embedding,慢且贵
结构感知 按 Markdown / HTML 的标题层级切 技术文档、网页 依赖良好结构
父子 / 层级 小「子块」入库检索,大「父块」喂 LLM 既要精准检索又要完整上下文 实现稍复杂
Late Chunking 长上下文模型先整篇 Embedding,再 pooling 成块向量 解决「它指代谁」问题(如 jina-embeddings-v3) 模型门槛高,尚非主流

递归字符切分为什么是默认 :它先尝试在段落边界切,切不动再降级到句子、词、字符,最大程度保住语义单元。LangChain 的 RecursiveCharacterTextSplitter(separators=["\n\n","\n",". "," ",""]) 就是这个思路。绝大多数 flat 散文,从这招起步就对了。

2.5 我们的实现:段落优先 + 滑窗 overlap

具体到代码,采用「段落优先」策略:

  1. 优先按段落切\n\n 是天然语义边界,段内主题相对集中)。
  2. 超长段落再按字符滑窗切。
  3. 段间不重叠------这是我们的设计选择:短段落本身是完整语义单元,段落间再重叠是纯冗余(注意 LangChain 在段落偏短时仍会跨段重叠,两种取舍都合理,看语料)。
  4. 每块带上 chunk_index(全局连续)+ source 元数据,保证检索结果可追溯------将来能说清「这段话来自第几块、什么来源」。

滑窗的灵魂只有一行:start = end - chunk_overlap。下一块不从 end 起步,而是往回吐 overlap 个字符------这就是相邻块重叠的来源。

python 复制代码
# app/rag/chunker.py --- split_text 核心实现(示意)
def split_text(text, chunk_size=800, chunk_overlap=100, source="profile"):
    paras = [p.strip() for p in text.split("\n\n") if p.strip()]
    raw_chunks = []
    for para in paras:
        if len(para) <= chunk_size:
            raw_chunks.append(para)                 # 短段落整段保留
        else:
            start = 0
            while start < len(para):
                end = min(start + chunk_size, len(para))
                raw_chunks.append(para[start:end])
                if end == len(para):
                    break
                start = end - chunk_overlap          # 灵魂:下一块回退 overlap
    return [{"chunk_text": c, "chunk_index": i, "source": source}
            for i, c in enumerate(raw_chunks)]

2.6 别凭感觉调:用 eval set 验证

切分参数没有「万能值」,只有「对你的语料最合适的值」。业内铁律:先建 20--50 条真实用户问题的 eval set,再动 splitter 。用 hit@5MRR 打分,每次只改一个变量(size / overlap / strategy),跑前跑后对比。没有 eval set,你是在凭感觉调参。

两个常被忽略的增强点:

  • 元数据过滤:把来源、章节、时间戳写进元数据,检索时能用「只搜 2024 年文档」做预过滤,精度大升。
  • 上下文富化:把章节标题、文档名前缀到每块上(Anthropic 实测失败率从 5.7% 降到 3.7%),比盲目改切分尺寸更划算。

小结:切分是 RAG 的第一棒,下游 Embedding 和向量库都靠它供干净的块。默认从「递归字符切分 + 512 token + 10% overlap」起步,按 token 计数,宁可先上 eval set 再微调。策略优先级:分块 > 模型 > 向量库。


总小结 :本文两件事------流式响应和文本切分------内核是同一个心智:大东西别一次性处理,切成可控小块,逐块流转 。一个分块读文件 (生成器 + StreamingResponse,恒定内存、天然背压),一个分块切文本 (段落优先 + 滑窗 overlap,喂给 RAG 干净的语义单元)。工程上记得两条默认配置:流式下载用 64KB 分块 + basename 防穿越;RAG 切分用递归字符切分 512 token / 10% overlap 起步,并用 eval set 收口。把这两棒接好,RAG 链路就稳了前半段。

相关推荐
OPEN-F1 小时前
Python进阶教程:正则表达式进阶与文本处理
开发语言·python·正则表达式
mqiqe1 小时前
AgentScope Java 2.0 Agent 状态存储(AgentStateStore)深度解析:构建可恢复、可扩展的智能体运行时
java·运维·网络
新网企兴1 小时前
2026年陕西做GEO推广,找新网企兴解决获客难题
python·搜索引擎
青 春 记 忆1 小时前
LeetCode 142. 环形链表 II|Python 解法详解
python·leetcode·链表
for_ever_love__1 小时前
python基础语法学习: 面向对象的三大特性
开发语言·python·学习
Spider赵毅2 小时前
Python爬虫踩坑实录:BeautifulSoup使用中的高频问题排查
爬虫·python·beautifulsoup
怪奇云呼军3 小时前
知识库也会注入指令?闪电智能VoiceAgent 如何防住 Prompt Injection
人工智能·python·算法·云计算·音视频
Canmag 兴隆磁性4 小时前
C 系列充磁机
c语言·开发语言·学习·永磁材料·充磁·退磁
zmzb01034 小时前
Python课后习题训练记录Day194
python·opencv·计算机视觉