☰
Cursor实战案例-Web开发-40-大文件分片上传:用FastAPI+TaoToken实现交易日志与策略大包的切片断点续传
2026/10/12 12:10:27 网站建设 项目流程

1. 交易日志大包上传为什么会卡在 99%

做量化回测的朋友大概率遇到过这种场景:一个 2.3GB 的backtest_2026Q1.zip,里面塞满了逐笔成交日志、tick 级行情快照和策略参数快照。用浏览器直接 POST 上传,进度条爬到 99% 时网络抖了一下,页面转圈三分钟后报net::ERR_CONNECTION_RESET,然后一切归零,只能从头再来。这不是浏览器的问题,是单次 HTTP 请求承载 GB 级 body 的天然缺陷。

传统单包上传有三个绕不开的物理瓶颈。第一是网络抖动导致整包重传,2GB 文件传到 1.9GB 时断线,服务端已经收到的字节全部作废,因为 HTTP 是无状态的,它不知道你上次传到了哪里。第二是服务端内存溢出,如果后端用await file.read()把整个文件读进内存再落盘,三个并发上传就能把 8GB 内存的机器打爆,进程被 OOM Killer 干掉。第三是磁盘碎片泛滥,客户端中途放弃后,服务端/tmp下残留的临时文件没人清理,跑一周硬盘就满了。

分片上传加断点续传是解决这类问题的标准答案。核心思路是把大文件切成固定大小的块(比如 5MB),每块独立上传,服务端记录哪些块已经收妥。网络断了重连后,客户端先问服务端"我传到哪了",然后只补传缺失的块。全部块到齐后,服务端按顺序拼接并做一次整体 MD5 校验,确保传输过程中没有字节损坏。这套机制在弱网环境下的价值尤其明显——地铁里、酒店 WiFi 下、跨国专线上传交易数据包,断点续传能把重传量从 GB 级压到 MB 级。

本文要搭的这套系统包含三个接口:/api/v1/upload/check做断点检测和秒传判断,/api/v1/upload/chunk接收单个分片,/api/v1/upload/merge负责合并与 MD5 终验。状态追踪用 Redis 的 Set 结构存已上传的分片索引,临时分片落盘到独立目录。同时我会把模型调用凭证的管理也纳入进来——量化系统里经常要调 LLM 做日志摘要或策略解释,用 TaoToken 的统一 Key 通道管理这些调用,避免在代码里散落一堆 API Key。下面从环境准备开始,一步步把可运行的代码搭出来。

2. TaoToken 统一 Key 通道与 FastAPI 环境准备

在动手写上传接口之前,先把模型调用的凭证管理理顺。量化系统里常见的做法是把 OpenAI Key、Claude Key、各家模型的 endpoint 硬编码在配置文件里,时间一长就变成一锅粥:测试环境用了生产 Key、某个 Key 额度耗尽导致整个日志分析流水线挂掉、换模型时要改十几个文件。TaoToken 提供的是一个统一的 API 通道,你只需要维护一个 Key,通过它来路由到不同的模型。

官网入口在 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,注册后在控制台生成 API Key。API 基地址是 https://taotoken.net/api ,注意这个地址不带 UTM 参数,直接用于代码里的base_url。控制台里可以创建多个 Key 做环境隔离,比如quant-dev、quant-prod,每个 Key 单独设额度上限,这样测试环境跑飞了也不会影响生产。

环境准备分两块。第一块是 Python 依赖,建议用虚拟环境隔离:

python3 -m venv venv source venv/bin/activate pip install fastapi==0.103.1 uvicorn==0.23.2 redis==5.0.1 python-multipart==0.0.6 pydantic==2.3.0 openai==1.12.0

第二块是 Redis,分片状态存在这里。macOS 用brew install redis && brew services start redis,Ubuntu 用sudo apt install redis-server && sudo systemctl start redis。启动后用redis-cli ping验证,返回PONG就通了。

关于 TaoToken 的 Key 配置,我建议放在环境变量里而不是代码里。在项目根目录建一个.env文件:

TAOTOKEN_API_KEY=sk-your-key-here TAOTOKEN_BASE_URL=https://taotoken.net/api REDIS_HOST=127.0.0.1 REDIS_PORT=6379 UPLOAD_DIR=/tmp/quant_uploads

然后在 Python 里用os.getenv读取。这样做的原因是:上传服务可能跑在容器里,Key 通过环境变量注入比挂载配置文件更安全,也不会因为误提交.env到 Git 而泄露。如果你用 Cursor 做开发,可以在 Cursor 的设置里配置环境变量,或者直接在终端export后再启动 uvicorn。

模型调用这块,量化系统里典型用途是上传完成后自动生成一份日志摘要。比如交易日志包合并成功后,调一次 LLM 让它读前 2000 行日志,输出"今日异常订单数量、最大回撤触发次数、策略信号分布"这样的结构化摘要。用 TaoToken 的 OpenAI 兼容接口,代码大概是这样:

from openai import OpenAI import os client = OpenAI( api_key=os.getenv("TAOTOKEN_API_KEY"), base_url=os.getenv("TAOTOKEN_BASE_URL") ) def summarize_trade_log(log_path: str) -> str: with open(log_path, "r", encoding="utf-8", errors="ignore") as f: head = "".join([next(f) for _ in range(2000)]) resp = client.chat.completions.create( model="claude-3-5-sonnet", messages=[ {"role": "system", "content": "你是量化交易日志分析助手,输出简洁的结构化摘要。"}, {"role": "user", "content": f"分析以下交易日志,统计异常订单数、最大回撤触发次数、策略信号分布:\n{head}"} ], temperature=0.2 ) return resp.choices[0].message.content

这段代码的关键点是base_url指向 TaoToken 的 API 地址,model字段填你想用的模型 ID。TaoToken 的通道会自动路由到对应的模型提供商,你不需要改代码就能切换模型。对于需要长期跑编码任务或 Agent 的场景,可以考虑 Coding Plan,它在额度管理上更适合高频调用。

环境搭好后,目录结构建议这样组织:

quant-upload/ ├── main.py # FastAPI 主服务 ├── uploader.py # 分片上传核心逻辑 ├── llm_summary.py # TaoToken 模型调用 ├── .env # 环境变量 └── test_upload.py # 测试脚本

接下来进入核心代码部分。

3. 可复制的分片表结构与上传合并路由配置

这一节给出完整的可运行代码。先定义分片状态的数据结构,再写三个路由。Redis 里用两个 Key 来追踪状态:upload:chunks:{file_md5}是一个 Set,存已成功接收的分片索引;upload:completed:{file_md5}存合并后的文件路径,用于秒传判断。

先看main.py的完整实现:

# -*- coding: utf-8 -*- import os import shutil import hashlib from fastapi import FastAPI, UploadFile, File, Form, HTTPException, Query from fastapi.middleware.cors import CORSMiddleware import redis from dotenv import load_dotenv load_dotenv() UPLOAD_DIR = os.getenv("UPLOAD_DIR", "/tmp/quant_uploads") TEMP_DIR = os.path.join(UPLOAD_DIR, "temp") os.makedirs(UPLOAD_DIR, exist_ok=True) os.makedirs(TEMP_DIR, exist_ok=True) app = FastAPI(title="量化大文件分片传输系统", version="1.0.0") app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) rdb = redis.Redis( host=os.getenv("REDIS_HOST", "127.0.0.1"), port=int(os.getenv("REDIS_PORT", 6379)), db=0, decode_responses=True ) @app.get("/api/v1/upload/check") async def check_file_upload(file_md5: str = Query(..., description="文件整体MD5指纹")): """断点检测:返回秒传状态或已上传分片列表""" completed_key = f"upload:completed:{file_md5}" existing_path = rdb.get(completed_key) if existing_path and os.path.exists(existing_path): return { "status": "completed", "message": "秒传成功:服务端已存在此文件", "file_url": existing_path } chunks_key = f"upload:chunks:{file_md5}" uploaded = rdb.smembers(chunks_key) uploaded_list = sorted([int(x) for x in uploaded]) return { "status": "uploading", "message": "可以上传,返回已完成的分片列表", "uploaded_chunks": uploaded_list } @app.post("/api/v1/upload/chunk") async def upload_file_chunk( chunk: UploadFile = File(..., description="分片文件对象"), file_md5: str = Form(..., description="文件总MD5"), chunk_index: int = Form(..., description="当前分片索引,从0开始"), total_chunks: int = Form(..., description="总分片数") ): """接收单个分片,落盘到临时目录""" chunk_dir = os.path.join(TEMP_DIR, file_md5) os.makedirs(chunk_dir, exist_ok=True) chunk_path = os.path.join(chunk_dir, f"{chunk_index}.tmp") try: with open(chunk_path, "wb") as buffer: shutil.copyfileobj(chunk.file, buffer) chunks_key = f"upload:chunks:{file_md5}" rdb.sadd(chunks_key, chunk_index) rdb.expire(chunks_key, 24 * 3600) return { "status": "success", "message": f"分片 {chunk_index} 接收完毕", "chunk_index": chunk_index } except Exception as e: raise HTTPException(status_code=500, detail=f"分片写入异常: {str(e)}") @app.post("/api/v1/upload/merge") async def merge_file_chunks( file_md5: str = Form(..., description="文件总MD5"), filename: str = Form(..., description="文件原始名称"), total_chunks: int = Form(..., description="总分片数") ): """合并分片并做MD5终验""" chunk_dir = os.path.join(TEMP_DIR, file_md5) if not os.path.exists(chunk_dir): raise HTTPException(status_code=400, detail="未找到该文件的分片目录") target_path = os.path.join(UPLOAD_DIR, filename) try: with open(target_path, "wb") as target_file: for i in range(total_chunks): chunk_path = os.path.join(chunk_dir, f"{i}.tmp") if not os.path.exists(chunk_path): raise HTTPException(status_code=400, detail=f"合并失败:缺少分片 {i}") with open(chunk_path, "rb") as chunk_file: target_file.write(chunk_file.read()) hasher = hashlib.md5() with open(target_path, "rb") as target_file: while chunk := target_file.read(8192): hasher.update(chunk) reconstructed_md5 = hasher.hexdigest() if reconstructed_md5 != file_md5: os.remove(target_path) raise HTTPException(status_code=400, detail="文件校验失败:MD5不匹配") shutil.rmtree(chunk_dir) rdb.set(f"upload:completed:{file_md5}", target_path) rdb.delete(f"upload:chunks:{file_md5}") return { "status": "success", "message": "文件全部合并完成且MD5校验一致", "file_url": target_path } except Exception as e: if isinstance(e, HTTPException): raise e raise HTTPException(status_code=500, detail=f"文件合并故障: {str(e)}")

这段代码里有几个设计决策值得说明。第一,分片落盘用shutil.copyfileobj而不是await chunk.read(),前者是流式拷贝,每次只处理 64KB 缓冲区,2GB 文件上传时内存占用稳定在几 MB。第二,Redis 的 Set 结构天然去重,同一个分片重复上传不会产生副作用,sadd是幂等的。第三,expire设了 24 小时,防止客户端放弃上传后临时分片永久残留。第四,合并时按索引顺序读取,如果中间缺片直接报错,不会产生一个残缺的文件。

关于分片大小,5MB 是一个平衡点。太小会导致请求数暴增(2GB 文件切成 400 个请求),太大则单次失败重传成本高。如果你的网络环境特别差,可以降到 2MB;如果是内网千兆环境,可以提到 20MB。这个值在客户端切片时决定,服务端不关心分片大小,只按索引拼接。

启动服务:

uvicorn main:app --host 0.0.0.0 --port 8000 --reload

访问http://localhost:8000/docs能看到自动生成的 Swagger 文档,三个接口都在里面,可以直接在页面上测试。

4. 验证请求与 MD5 一致性校验实操

服务跑起来后,用 Python 脚本模拟一次完整的"上传中断再续传"流程。这个脚本会故意在传完两个分片后抛异常,然后重新发起上传,验证断点续传是否生效。

# -*- coding: utf-8 -*- import os import hashlib import requests import logging logging.basicConfig(level=logging.INFO, format="%(asctime)s - [%(levelname)s] - %(message)s") BASE = "http://localhost:8000/api/v1/upload" CHUNK_SIZE = 5 * 1024 * 1024 def calc_md5(path): h = hashlib.md5() with open(path, "rb") as f: while chunk := f.read(8192): h.update(chunk) return h.hexdigest() def upload_with_resume(file_path, simulate_crash_after=2): file_md5 = calc_md5(file_path) file_size = os.path.getsize(file_path) total_chunks = (file_size + CHUNK_SIZE - 1) // CHUNK_SIZE logging.info(f"文件MD5: {file_md5}, 总分片数: {total_chunks}") # 第一次:检查断点 r = requests.get(f"{BASE}/check", params={"file_md5": file_md5}) data = r.json() if data["status"] == "completed": logging.info("秒传成功,无需上传") return data["file_url"] uploaded = set(data.get("uploaded_chunks", [])) logging.info(f"已上传分片: {sorted(uploaded)}") # 上传分片,模拟中途崩溃 for i in range(total_chunks): if i in uploaded: logging.info(f"分片 {i} 已存在,跳过") continue if simulate_crash_after is not None and i >= simulate_crash_after: logging.warning("模拟网络中断,终止上传进程") raise ConnectionError("simulated network failure") with open(file_path, "rb") as f: f.seek(i * CHUNK_SIZE) blob = f.read(CHUNK_SIZE) files = {"chunk": (f"{i}.tmp", blob)} form = {"file_md5": file_md5, "chunk_index": i, "total_chunks": total_chunks} resp = requests.post(f"{BASE}/chunk", files=files, data=form) logging.info(f"分片 {i}/{total_chunks} 上传: {resp.json()['status']}") # 合并 merge_form = {"file_md5": file_md5, "filename": os.path.basename(file_path), "total_chunks": total_chunks} resp = requests.post(f"{BASE}/merge", data=merge_form) logging.info(f"合并结果: {resp.json()}") return resp.json().get("file_url") if __name__ == "__main__": test_file = "/tmp/backtest_data_2026.zip" if not os.path.exists(test_file): with open(test_file, "wb") as f: f.write(os.urandom(12 * 1024 * 1024)) # 生成12MB测试文件 try: upload_with_resume(test_file, simulate_crash_after=2) except ConnectionError: logging.info("=== 第一次上传中断,重新发起续传 ===") upload_with_resume(test_file, simulate_crash_after=None)

运行这个脚本,你会看到类似这样的输出:

2026-06-22 13:00:00 - [INFO] - 文件MD5: a8b4c09d57a2e8c23d149023efd688cf, 总分片数: 3 2026-06-22 13:00:00 - [INFO] - 已上传分片: [] 2026-06-22 13:00:01 - [INFO] - 分片 0/3 上传: success 2026-06-22 13:00:02 - [INFO] - 分片 1/3 上传: success 2026-06-22 13:00:02 - [WARNING] - 模拟网络中断,终止上传进程 2026-06-22 13:00:02 - [INFO] - === 第一次上传中断,重新发起续传 === 2026-06-22 13:00:05 - [INFO] - 已上传分片: [0, 1] 2026-06-22 13:00:05 - [INFO] - 分片 0 已存在,跳过 2026-06-22 13:00:05 - [INFO] - 分片 1 已存在,跳过 2026-06-22 13:00:06 - [INFO] - 分片 2/3 上传: success 2026-06-22 13:00:07 - [INFO] - 合并结果: {'status': 'success', 'message': '文件全部合并完成且MD5校验一致', 'file_url': '/tmp/quant_uploads/backtest_data_2026.zip'}

关键验证点有三个。第一,第二次上传时check接口返回了[0, 1],说明 Redis 里的分片状态被正确持久化了。第二,分片 0 和 1 被跳过,只传了分片 2,这就是断点续传的实际效果。第三,合并接口返回的 MD5 校验一致,说明拼接后的文件字节级正确。

如果你想手动验证 MD5,可以在合并完成后跑:

md5sum /tmp/backtest_data_2026.zip

对比脚本里计算的file_md5,两者应该完全相同。如果不同,说明传输过程中有字节损坏,合并接口会直接拒绝并删除目标文件。

再测一下秒传:对同一个文件再跑一次上传脚本,这次check接口会直接返回completed,整个上传流程在第一个请求就结束了,零字节传输。这在量化系统里很实用——同一个回测数据集被多个策略引用时,第二个策略上传时直接秒传,省掉重复传输。

5. 常见报错排查:401、local proxy failed 与 reading choices

实际部署时最容易踩的坑集中在两类:上传接口本身的错误,以及 TaoToken 模型调用时的认证和网络问题。下面按报错原文逐个拆解。

报错一:401 Unauthorized调用模型接口时

完整报错通常是openai.AuthenticationError: Error code: 401 - {'error': {'message': 'Invalid API key'}}。原因是你传给OpenAI(api_key=...)的值不对,或者环境变量没加载。排查步骤:先在终端echo $TAOTOKEN_API_KEY确认变量存在,然后在 Python 里print(os.getenv("TAOTOKEN_API_KEY")[:8])看前八位是否和 TaoToken 控制台里的一致。注意 Key 不要有多余空格或换行,从控制台复制时容易带上尾部空白。如果用的是.env文件,确认load_dotenv()在读取环境变量之前调用。

报错二:local proxy failed或连接超时

这个报错说明请求根本没到达 TaoToken 的服务器。常见原因是本机设置了 HTTP 代理环境变量,比如HTTP_PROXY或HTTPS_PROXY指向了一个不可用的地址。检查env | grep -i proxy,如果有输出,在启动服务前unset HTTP_PROXY HTTPS_PROXY。另一个原因是 DNS 解析问题,用curl -v https://taotoken.net/api看能否建立连接。如果公司网络有出口限制,需要联系网络管理员放行。

报错三:reading choices相关错误

完整报错类似KeyError: 'choices'或AttributeError: 'NoneType' object has no attribute 'choices'。这通常发生在模型返回了非预期结构时。原因可能是模型 ID 写错了,比如把claude-3-5-sonnet写成了claude-3.5-sonnet,TaoToken 路由不到对应模型,返回了一个错误结构。排查方法是打印完整响应:

resp = client.chat.completions.create(...) print(resp.model_dump_json(indent=2))

看返回体里有没有choices字段。如果没有,检查model参数是否在 TaoToken 支持的模型列表里。另外,如果请求内容触发了内容安全策略,也可能返回非标准结构,这时候需要调整 prompt。

报错四:OSError: [Errno 28] No space left on device

合并大文件时磁盘满了。2GB 的文件合并需要额外 2GB 临时空间,如果/tmp分区只有 4GB,传两个文件就爆了。解决方案是在合并前检查磁盘余量:

import shutil total, used, free = shutil.disk_usage(UPLOAD_DIR) if free < file_size * 1.2: raise HTTPException(status_code=507, detail="服务器存储空间不足")

同时把UPLOAD_DIR指向大容量分区,比如/data/quant_uploads,别用默认的/tmp。

报错五:PermissionError: [Errno 13] Permission denied

FastAPI 进程对上传目录没有写权限。如果服务用www-data用户跑,而目录属主是root,就会报这个。解决:chown -R www-data:www-data /tmp/quant_uploads或chmod -R 755 /tmp/quant_uploads。生产环境不建议用 777,权限放太开有安全风险。

报错六:分片上传后合并报"缺少分片 X"

这说明某个分片没成功落盘,但 Redis 里记录了它。可能原因是分片写入磁盘时进程崩溃,sadd执行了但文件没写完。排查方法是检查TEMP_DIR/{file_md5}/下实际存在的.tmp文件,和 Redis 里的 Set 对比。修复方式是在check接口里增加一次磁盘校验,只返回磁盘上真实存在的分片索引:

uploaded_list = [] for idx in rdb.smembers(chunks_key): if os.path.exists(os.path.join(TEMP_DIR, file_md5, f"{idx}.tmp")): uploaded_list.append(int(idx))

这样即使 Redis 状态和磁盘不一致,客户端也会重传缺失的分片。

关于 TaoToken 的接入配置,如果你在 Cursor 里用 Claude Code 做开发,需要配全三件套:Base URL 填https://taotoken.net/api,API Key 填控制台生成的 Key,Model ID 填你要用的模型。这三个值缺一不可,只填 Key 不填 Base URL 会走到默认的 OpenAI 地址,报 401。接入文档在 https://taotoken.net/doc 有详细说明,API Keys 管理在 https://taotoken.net/api-keys 。

6. 把上传服务接入你的量化工作流

这套分片上传系统跑通后,可以往几个方向扩展。第一是加一个定时清理任务,用 APScheduler 每天凌晨扫描TEMP_DIR,把修改时间超过 72 小时的临时目录删掉,防止客户端放弃上传后磁盘被慢慢吃满。第二是给合并接口加一个异步任务队列,大文件合并耗时较长时先返回202 Accepted,合并完成后通过 webhook 通知客户端,避免 HTTP 请求超时。第三是把模型调用串进上传流程——文件合并成功后自动触发一次日志摘要,用 TaoToken 的通道调 LLM 分析交易日志,把摘要写回数据库,这样策略研究员上传完数据就能直接看到关键指标。

如果你需要长期跑这类编码和 Agent 任务,Coding Plan 在额度管理上比按次调用更划算。模型对话入口在 https://taotoken.net/chat ,可以先用它测试 prompt 效果,确认输出格式符合预期后再写进代码。控制台在 https://taotoken.net/console ,能看每个 Key 的调用量和余额。

最后留一个实际踩过的坑:分片大小不要设成 1MB 以下。我试过用 512KB 切片传一个 3GB 的文件,产生了 6000 多个请求,Redis 的 Set 里塞了 6000 个成员,合并时循环 6000 次读文件,光系统调用开销就花了十几秒。5MB 到 10MB 是甜点区间,请求数控制在几百个,单次失败重传成本也可接受。另外,客户端计算大文件 MD5 时一定要用增量计算(SparkMD5 的ArrayBuffer模式或 Python 的分块update),别把整个文件读进内存算哈希,否则浏览器标签页会直接崩掉。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询