一个线程只有一个事件循环:"同步入口"全在抢所有权
业务跑完,评测 7/7 达标,端到端全通。
然后崩在 finally 里的那一行。
这篇讲三颗"收尾雷",但重点不在怎么修——修法都是一行的事。重点在:它们该按什么顺序修,而这个顺序和直觉相反。
一、三颗雷,两个病
先摆出来:
| 编号 | 形态 | 报错 |
|---|---|---|
| EC-026 | 协程里手写 loop.run_until_complete() | This event loop is already running |
| EC-007 | 协程里调 pydantic-ai 的 run_sync() | 同上,栈在 SDK 里 |
| EC-028 | close_models(models) 多传一个参 | takes 0 positional arguments but 1 was given |
前两颗是同一个病:抢事件循环的所有权。第三颗是签名不匹配。
但它们值得单独说的原因,不在病根,在代价。
二、所有权的规则只有一条
同一线程同一时刻,只能有一个正在运行的 loop,而且只有它的所有者能驱动它。
你写 asyncio.run(main()) 的那一刻,asyncio.run 就是所有者。main() 和它 await 的一切,都跑在这个正在转的循环里。
这时候你再写:
loop = asyncio.get_running_loop()
loop.run_until_complete(some_task())
run_until_complete 的语义是"启动这个循环,跑到某协程结束"。循环已经在跑,所以它第一步就执行 _check_running()——这不是异常处理,是一条断言:循环不能已经在跑。
于是:
RuntimeError: This event loop is already running
顺带一个细节:崩的同时,Python 还会在 stderr 打一句 coroutine 'inner' was never awaited。
因为它把协程对象造出来了,却永远没机会执行。这行警告的意思是"这个协程根本没跑",不是"跑失败了"。 排查时这两者差别很大。
三、真正让人踩坑的是名字
如果只是自己写错 run_until_complete,那还好——错误明显。
麻烦的是第二种:SDK 替你写的。
result = agent.run_sync("推荐一款适合干性皮肤的产品")
报错栈:
File ".../pydantic_ai/agent/abstract.py", line 793, in run_sync
return _utils.run_until_complete(...)
...
RuntimeError: This event loop is already running
run_sync 读起来像"同步跑一下,省得写 await"。实际语义是"我要自己起一个循环"。
pydantic-ai 的两套入口:
| 入口 | 性质 | 适用 |
|---|---|---|
agent.run_sync(x) | 内部自建循环 | 顶层脚本,没有循环时 |
await agent.run(x) | 纯异步 | async 函数内部 |
所以规则可以写成一句:
只要入口是 asyncio.run(main()),脚本里所有带 _sync 后缀的快捷入口都是地雷。
它们不是"同步版",是"抢所有权版"。同理还有 asyncio.get_event_loop().run_until_complete(...)、loop.run_forever()。
四、判断只需要一个布尔值
在写任何"起循环"的调用之前,先确认自己在哪:
import asyncio
def in_loop() -> bool:
try:
asyncio.get_running_loop()
return True
except RuntimeError:
return False
实跑:
模块顶层(没有 loop) : False
协程内部(loop 正在跑) : True
拿到这个值,能做什么就定了:
| 你在哪 | 能用 | 不能用 |
|---|---|---|
False | asyncio.run(coro) | await(没有循环) |
True | await coro | 一切"起循环"的调用 |
get_running_loop() 在没循环时抛 RuntimeError。它不是异常,是一句"现在有没有人在开这台车"的回答。
五、最贵的那颗,报错最不吓人
第三颗雷:close_models() 定义是 0 参数,你传了 models 字典。
TypeError: close_models() takes 0 positional arguments but 1 was given
看起来是最"低级"的一个——数错参数而已。但它落在 finally 里,性质就变了。
如果 try 里已经有异常在往外冒,finally 又抛一个,屏幕上你只会看到最后一个。
最小复现:
def f():
try:
raise ValueError("真正的原因:模型返回了空内容")
finally:
raise TypeError("收尾时又错了一次")
实跑:
屏幕上看到的 : TypeError: 收尾时又错了一次
被盖掉的 : ValueError: 真正的原因:模型返回了空内容
Python 没丢信息——原异常在 e.__context__ 上,终端也会打 During handling of the above exception, another exception occurred。
但人的眼睛只看最后一行。
于是你会花半小时修 TypeError,改完重跑,才看到那个真正让脚本崩的 ValueError。一行收尾代码,付了两次排查成本。
六、所以严重度要倒过来排
四颗雷(加上上一篇那颗无害的收尾噪音):
| 编号 | 报错有多吓人 | 影响结果 | 盖住别的错 |
|---|---|---|---|
EC-001 generator didn't stop | 很吓人(四十行红字) | 否 | 否 |
| EC-026 手写套娃 | 一般 | 是 | 否 |
| EC-007 SDK 套娃 | 一般 | 是 | 否 |
| EC-028 签名不匹配 | 最不吓人(一行) | 是 | 是 |
按"吓人程度"排,EC-001 第一,EC-028 最后。 按"该先修哪个"排,正好反过来。
判断标准一句话:它会不会改变我对结果的判断?
- 不会(EC-001)→ 记下来,别动
- 会,但只影响自己(EC-026/007)→ 修掉,代价可控
- 会,而且让我误判别处(EC-028)→ 优先修,它在污染排查过程
我在这上面栽过:先花两小时追那屏四十行的红字,最后发现它无害;而真正让脚本崩的那行 TypeError,因为它"看起来太简单",被我放到最后。
七、怎么给"退出"补一个测试
收尾难测,但不是不能测。关键是:不要在进程内测,起一个真进程。
import subprocess, sys
def test_exit_clean():
r = subprocess.run([sys.executable, "main.py"],
capture_output=True, text=True, timeout=60)
assert r.returncode == 0, r.stderr[-800:]
assert "Traceback" not in r.stderr, r.stderr[-800:]
两条断言,覆盖了这颗雷的两种表现:
- 退出码不是 0 → 崩了(EC-026 / EC-007 / EC-028)
- stderr 里有 Traceback 但退出码是 0 → 收尾噪音(EC-001),记下来别改
为什么必须在子进程里跑?
因为"退出"这个动作,在测试进程里测不到。测试框架自己持有事件循环,你的脚本从没真正"退出"过。
这也是为什么这类问题特别容易漏——它不是"没写测试",是测试这种形式天然覆盖不到退出路径。
代价是慢:要起进程、要真调模型。所以我不放进每次 CI,只在两个时机跑:
- 改过收尾代码之后
- 上线前
这两次的价值,比每天跑一遍高。
八、三问定位
三颗雷压成三问,按顺序:
| 顺序 | 问自己 | 判据 |
|---|---|---|
| 1 | 这个错是在收尾阶段吗? | 业务输出是否已完整打印。是 → 查异步/收尾层 |
| 2 | 报的是 This event loop is already running? | 先找自己写的 run_until_complete,再找 SDK 的 *_sync |
| 3 | 报的是 takes 0 positional arguments? | 先数参数;在 finally 里立刻看 __context__ |
第一问决定"去哪找",第二问决定"是不是所有权",第三问决定"这个错是不是真的"。
三问都不用调试器,看着输出就能答。
九、有人说:nest_asyncio 不就解决了
能解决,但我不建议。
它的原理是给循环打补丁,允许重入。等于承认"我要在同一台发动机上装第二个起动机"。
短期你会看到它"能跑了"。长期它会以更难查的方式坏:任务被抢占、超时计时错乱、取消传播异常。而这些问题的表现,和"套娃"完全不像,你会当成新问题重新查一遍。
改调用结构只要一行,打补丁要一直维护。
十、两个边界
一、多线程里规则要改写。
每个线程可以各有一个循环,"谁拥有"变成"哪个线程拥有"。跨线程 await 不允许,得用 run_coroutine_threadsafe。
二、Web 服务里没有"退出"这个信号。
uvicorn / gunicorn 自己持有循环,报错时机从"脚本退出"变成"某个请求处理时"。in_loop() 依然有用,但"跑完看退出干不干净"这个动作没了,只能靠日志和中间件。
十一、最后
三颗雷修法都很短:
- 入口只留一个
asyncio.run,内部全await - 带
_sync的调用先问"我在不在循环里" finally里的收尾包一层try/except,记日志不抛出
第三条是最后加的,也最重要——它不防止出错,它防止你丢线索。
因为前面几百行业务代码一行没错,这是好事。但"没错"不等于"没问题":如果收尾把真正的错误盖住,你连自己没错这件事都证明不了。
一个脚本可信,不是因为它跑出了正确结果,是因为它退出的时候没藏东西。
顺带一句:如果你手上正好有个脚本,跑完了会刷红字——先别改。把退出码和 stderr 最后三行记下来,再决定动不动它。大多数时候它不影响任何东西,而改动本身有风险。
这是「AgentScope 2.0 实战踩坑地图」的第 3 篇。上一篇讲"成功"也会指错方向;这一篇讲"收尾"会盖住错误。下一篇:同一套 DeepSeek,接四种框架四个坑。