从蜗牛到火箭:用 asyncio 重构 Gemini API 批量翻译脚本
1. 从“蜗牛”到“火箭”:一次性能瓶颈的深刻反思
前几天,我需要把一个英文技术文档项目(大概40多个Markdown文件)翻译成中文。第一反应就是用Gemini API写个脚本,感觉这活儿应该挺简单的。结果第一个版本跑起来,我盯着进度条,感觉时间都凝固了——处理完所有文件,花了好几分钟。这哪是AI翻译,简直是“人工”等待。我意识到,我的脚本成了一个彻头彻尾的“蜗牛”选手。
问题出在哪?我仔细捋了捋代码逻辑。脚本的工作流程非常“老实”:遍历文件夹,找到一个文件,打开它,把内容发给Gemini API,然后就开始等。等API返回结果了,把译文写进新文件,然后才处理下一个。这个过程中,我的程序绝大部分时间都在干一件事:等。等网络请求发出去,等Google的服务器处理,等结果传回来。CPU闲得发慌,而程序却在“空转”。
这种模式,我称之为“单线程笨蛋”模式。它就像只有一个收银台的超市,不管队伍排多长,都必须一个一个来,后面的人只能干着急。而在网络请求这种I/O密集型任务中,“等待”就是最大的性能杀手。每一个文件的处理时间,约等于“网络延迟 + AI处理时间 + 我的主动等待(time.sleep)”。当文件数量上去后,这些等待时间线性叠加,最终导致了令人绝望的总耗时。
更糟糕的是,我为了“礼貌”,还在每次请求后加了一句 time.sleep(1),怕请求太快被API限制。这无异于给蜗牛背上又加了一个重重的壳。40个文件,就意味着至少40秒是纯纯的、毫无意义的“罚站”时间。这次经历让我痛定思痛:当任务的核心瓶颈是I/O等待时,串行执行就是效率的坟墓。我们必须让程序在“等待”的时候,去干点别的有用功。这就是异步编程(asyncio)要解决的核心问题。
2. 异步思维:从“一根筋”到“多面手”
要解决“等待”的难题,我们得先换一个脑子。传统的同步编程是“顺序思维”:做完A,再做B,再做C。而异步编程是“事件驱动思维”:发起A,在等A的时候去做B,B也等了就去看看C有没有结果,谁先完成就先处理谁。
用一个生活中的比喻来说,同步编程就像你亲自下厨,先烧水,然后盯着水壶等它开,水开了再下面条,接着又盯着锅等面熟。整个过程你虽然很忙,但大量时间在闲置等待。异步编程则像一位老练的大厨,他烧上水后,不会干等,而是转身去切菜、备肉。水开了,他过来下面条,然后又去调酱汁。他总是在做“有用功”,统筹多个任务,最大化利用时间。
Asyncio 就是Python世界里实现这种“大厨思维”的利器。它不是开多个线程(那会引入复杂的锁和同步问题),而是在一个线程内,通过“协程”(Coroutine)和“事件循环”(Event Loop)来实现任务的切换。当一个协程遇到需要等待的操作(比如网络请求)时,它会主动说:“我先歇会儿,你去处理别的任务吧”。事件循环就接手,去运行其他已经就绪的协程。等之前那个网络请求有结果了,事件循环再回来唤醒它继续执行。
这种机制的精妙之处在于,切换的成本极低,不像线程切换那样需要操作系统介入。对于我们的批量翻译脚本来说,理想状态应该是:同时发起10个、20个文件的翻译请求,让它们“一起飞”向Gemini API。然后我们的程序不再傻等,而是去处理那些已经返回的结果,写入文件。从“逐个击破”变为“批量冲锋”,效率的提升将是数量级的。
3. 重构实战:核心代码的异步化改造
光说不练假把式,我们直接来看如何把那个“蜗牛”脚本,改造成“火箭”脚本。改造的核心围绕几个关键点:将同步函数变为异步函数,使用异步API,以及管理并发量。
3.1 定义异步翻译函数
这是最核心的一步。原来的同步函数 def translate_text(...) 需要被重构成异步函数。
import asyncio
import google.generativeai as genai
# 异步翻译核心函数
async def translate_text_async(text: str, model: genai.GenerativeModel) -> str:
"""
异步调用Gemini API进行翻译。
"""
prompt = prompt_template.format(english_text=text)
# 安全设置,根据你的需求调整
safety_settings = [
{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"},
{"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_NONE"},
{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "BLOCK_NONE"},
{"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_NONE"},
]
# 关键变化:使用异步版本的 generate_content
response = await model.generate_content_async(
prompt,
safety_settings=safety_settings
)
return response.text.strip()
注意两个关键变化:第一,函数定义用 async def;第二,调用API时使用 await model.generate_content_async()。这个 await 关键字就是“让权”的信号,它告诉事件循环:“这个函数要等网络返回,你先去忙别的,好了叫我。”
3.2 改造文件处理流程
单个文件的处理也需要包装成异步任务。这个函数负责读取文件、调用上面的异步翻译函数、写入结果。
async def process_file_async(input_path, output_dir, model, stats, pbar):
"""异步处理单个文件"""
# ... 计算输出路径等逻辑(与同步版相同)...
try:
if input_path.lower().endswith(('.md', '.markdown')):
with open(input_path, 'r', encoding='utf-8') as f:
content = f.read()
if not content.strip():
translated_content = ""
else:
# 核心:等待异步翻译完成
translated_content = await translate_text_async(content, model)
with open(output_path, 'w', encoding='utf-8') as f:
f.write(translated_content)
stats['success'] += 1
else:
# 非文本文件直接复制
shutil.copy2(input_path, output_path)
stats['copied'] += 1
except Exception as e:
stats['failed'] += 1
stats['failures'].append(f"处理失败: {relative_path}, 原因: {e}")
finally:
pbar.update(1) # 更新进度条
这里,文件读取和写入(open)仍然是同步操作,但因为速度很快,阻塞不是问题。主要的 await 用在了翻译函数上,这正是我们想要并行化的部分。
3.3 使用信号量(Semaphore)进行智能限流
一股脑同时发起几百个网络请求,会把自己的IP或者API配额打爆。我们需要一个“哨兵”来控制同时进行的任务数量,这就是 asyncio.Semaphore。
async def main(input_dir: str, output_dir: str):
# ... 收集所有待处理文件 ...
CONCURRENT_LIMIT = 10 # 控制最大并发数,比如10
semaphore = asyncio.Semaphore(CONCURRENT_LIMIT)
async def throttled_process_file(input_path, output_dir, model, stats, pbar):
async with semaphore: # 关键:只有拿到“许可证”的任务才能进入执行
await process_file_async(input_path, output_dir, model, stats, pbar)
# 可以在这里加一个很小的延迟,进一步平滑请求
await asyncio.sleep(0.05)
# 创建任务列表
tasks = [
throttled_process_file(fp, output_dir, model, stats, pbar)
for fp in files_to_process
]
# 并发执行所有任务
await asyncio.gather(*tasks)
Semaphore(10) 就像只发放10张通行证。前10个任务会立刻拿到通行证开始执行(发起API请求)。第11个任务到来时,发现通行证发完了,它就会在 async with semaphore: 这一行安静地等待,直到有前面的任务完成并归还了通行证。这样,我们始终将并发的API请求数控制在10个,既压榨了网络带宽,又避免了触发服务器的速率限制,是一种非常优雅的流量整形手段。
3.4 启动事件循环
最后,我们需要一个入口来启动整个异步世界。
if __name__ == "__main__":
input_directory = "your_source_docs"
output_directory = "translated_docs"
# 这是运行异步程序的标准方式
asyncio.run(main(input_directory, output_directory))
asyncio.run() 负责创建事件循环、运行我们的主协程 main(),并在所有任务完成后关闭循环。它是异步程序的“发动机”。
4. 性能对比:数字带来的震撼
理论说再多,不如实际跑一跑。我用同一个包含45个Markdown文件(大小从几KB到几十KB不等)的文件夹,分别测试了改造前后的脚本。测试环境是家庭千兆宽带,API密钥状态正常。
我设计了一个简单的对比实验:
| 测试项 | 同步版本(蜗牛版) | 异步版本(火箭版,并发数=10) |
|---|---|---|
| 总耗时 | 约 3 分 45 秒 | 约 28 秒 |
| 主要耗时环节 | 网络等待(串行) + 强制Sleep | 网络等待(并行) |
| CPU占用率 | 很低(<5%),大部分时间空闲 | 平稳(10-20%),用于任务调度和文件IO |
| 网络流量图 | 间歇性峰值,长时间空白 | 持续、密集的请求与响应,带宽利用率高 |
| 观感 | 进度条缓慢、一格一格跳动 | 进度条快速、平滑地前进 |
这个结果非常直观。同步版本耗时超过3分钟,其中绝大部分是无效的等待时间。而异步版本将总耗时压缩到了半分钟以内,性能提升了近8倍!这还不是极限,如果网络条件和API限制允许,适当提高并发限制(比如设为20),速度还能更快。
更重要的是资源利用率。在同步模式下,脚本运行期间,我的电脑几乎感觉不到负载,因为程序在“睡觉”。而在异步模式下,虽然CPU占用依然不高,但网络接口始终处于活跃状态,程序真正在“全力工作”。这种效率的提升,正是将程序从“I/O等待”的枷锁中解放出来的直接成果。
5. 避坑指南与最佳实践
异步编程很强大,但也有一些“坑”需要注意。我在重构和测试过程中总结了以下几点,能帮你少走弯路。
5.1 识别阻塞调用(Blocking Calls)
这是异步编程中最常见的错误。事件循环在一个线程内运行,如果你在一个协程里调用了同步的、阻塞式的函数(比如某些同步的HTTP请求库 requests.get(),或者没有异步版本的数据库查询),那么整个事件循环都会被这个函数“卡住”,直到它完成。这相当于让大厨去干一件不能中断的体力活,所有其他任务都得停下等他。
解决方案:
- 为常用库寻找异步替代品:用
aiohttp替代requests,用aiomysql替代pymysql。在我们的案例中,幸运的是google-generativeai库直接提供了generate_content_async方法。 - 使用线程池执行器:对于确实没有异步版本且耗时的CPU密集型或阻塞式I/O操作,可以将其放到一个单独的线程池中运行,避免阻塞事件循环。
asyncio提供了run_in_executor方法。import asyncio from concurrent.futures import ThreadPoolExecutor import time def blocking_io(): # 这是一个会阻塞的同步函数 time.sleep(2) return "Done" async def main(): loop = asyncio.get_running_loop() # 将阻塞函数提交到线程池执行 result = await loop.run_in_executor(None, blocking_io) print(result)
5.2 妥善处理异常
在同步代码中,异常会沿着调用栈向上抛出。在异步中,由于任务是被事件循环调度的,如果一个任务(协程)内部发生未处理的异常,这个异常默认不会立即崩溃整个程序,但会导致该任务静默失败。你可能会发现某个文件没处理,却不知道原因。
解决方案:
- 在协程内部做好异常捕获:就像我们在
process_file_async函数里用try...except做的那样,记录下失败的文件和原因。 - 使用
asyncio.gather的return_exceptions参数:将其设为True,gather会返回结果列表,其中的异常对象会作为正常结果返回,而不是直接抛出。这让你可以后续统一检查。results = await asyncio.gather(*tasks, return_exceptions=True) for i, result in enumerate(results): if isinstance(result, Exception): print(f"任务 {i} 失败: {result}") - 为任务添加回调进行异常处理:更高级的做法是为每个
asyncio.create_task()创建的任务添加add_done_callback,在回调函数中检查任务是否异常。
5.3 合理设置并发限制
并发数不是越大越好。设置得太高(比如100),可能会:
- 触发API的速率限制:导致大量请求被拒绝,返回429错误。
- 耗尽本地资源:同时维护大量网络连接和中间状态,可能消耗过多内存。
- 造成目标服务器压力过大:这是不友好的行为。
如何确定合适的值?
- 参考API文档:很多API会明确说明每秒或每分钟的请求限制(Rate Limit)。根据限制和单个请求的预估耗时来推算。
- 从小开始,逐步增加:可以先设为5,观察请求成功率和耗时,再慢慢调到10、15。找到一个成功率接近100%且耗时最短的甜蜜点。
- 考虑网络延迟:如果到API服务器的网络延迟很高(比如100ms以上),可以适当提高并发数,以填充等待时间,充分利用带宽。
在我的脚本中,将 CONCURRENT_LIMIT 设置为10是一个比较保守且有效的值,它在速度和稳定性之间取得了很好的平衡。你可以在你的网络环境下进行微调。
6. 思维升级:不仅仅是代码的重构
这次从同步到异步的重构,远不止是改了几行代码、换了个函数。它是一次编程思维的升级。我们从一个“顺序执行者”的视角,切换到了一个“资源调度者”的视角。
在同步思维里,我们关注的是“步骤”:先做A,再做B。在异步思维里,我们关注的是“事件”和“状态”:发起A、B、C等多个操作,然后监听哪些操作完成了(事件),就去处理它的结果(改变状态)。程序的主体从“执行链”变成了“反应器”。
这种思维对于现代应用开发至关重要。无论是构建高并发的Web服务器、编写高效的爬虫,还是处理大量的文件或数据库I/O,异步都是核心武器。它让我们能够用更少的硬件资源(比如单线程),服务更多的并发请求,写出响应更迅捷的程序。
回过头看这个翻译脚本,它现在不再是一个被动的“等待者”,而是一个主动的“管理者”。它创建一批任务,巧妙地控制着它们的并发节奏,在I/O的间隙高效地完成其他工作。这正是从“蜗牛”到“火箭”蜕变的本质:不是单纯跑得更快,而是用完全不同的方式在奔跑。当你下次再遇到I/O密集型的任务时,不妨先问问自己:我的程序,是不是也在“空等”?也许,asyncio就是那把打开效率之门的钥匙。
更多推荐


所有评论(0)