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-Disposition :attachment 触发浏览器下载并指定文件名;换成 inline 则直接在页面内预览(如播放 mp4)。配合正确的 media_type(MIME)效果更佳。
  • 真·异步 :如果你追求极致的非阻塞 I/O,用 aiofiles 做 async 生成器,避免任何线程切换开销。普通场景用同步生成器 + 线程池足矣。

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

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

现象 真相
明明用了 StreamingResponse,客户端的进度条还是卡住不动 反代 / 负载均衡器默认会缓冲响应,把好不容易拆好的块又攒成大块才发;需要关掉 buffering 或启用 chunked transfer
文本流比二进制流慢很多 没开 GZip;文本型流启用 GZipMiddleware(minimum_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_overlap :10%--20% 的 chunk_size。Chroma 的 472-query 基准里,「递归切分 512 token + 10% overlap」拿到 88.5% recall,和最贵的 LLM 语义切分只差 3 个点,且零额外成本------这是有数据支撑的默认起点。

token 计数比字符计数更准 :用 tiktoken 的 cl100k_base 做 length_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@5、MRR 打分,每次只改一个变量(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 链路就稳了前半段。

相关推荐
源流之道14 分钟前
联想Y700 五代(TB323FU)刷 ColorOS 16 教程指南
网络·ai·刷机指南·刷机心得
GEO从入门到精通25 分钟前
课程怎么选?2026年GEO学习资源全梳理
学习
魔猴疯猿41 分钟前
从0到1用Python开发第一个智能体
人工智能·python·深度学习·神经网络·机器学习
优化Henry1 小时前
LTE 载波频率与频点配置详解
运维·网络·学习·5g·信息与通信
xiaomu001231 小时前
高湿环境床垫湿度管理技术框架:湿度阈值、排湿结构与材料选型
经验分享·学习·学习方法
xiaoqi01952 小时前
机器学习因子分析实战:从量化特征到可视化决策
人工智能·python·机器学习
科力锐品牌君2 小时前
应用级灾备 | 海量非结构化数据如何实现高效数据保护
linux·运维·网络·安全·系统安全·数据安全·灾备
Escalating_xu2 小时前
【Python】基础语法(1):常量、变量、类型、输入输出与运算符
开发语言·python
zhangrelay2 小时前
ROS项目设计案例智能大模型正经乱答案例
linux·笔记·学习·ubuntu·机器人
probex_2 小时前
学习笔记:知识图谱是什么,在测试里能干什么
笔记·学习·知识图谱