← 文章

async 生成器被 break 以后会 aclose:最后一个 yield 之后的代码不执行

约 5 分钟#Python#asyncio#流式输出#Autarkia

消费方拿到最后一个事件就 break,async 生成器被 aclose(),GeneratorExit 抛进暂停的 yield,后面的 create_task 不会执行,finally 会。Autarkia 的对话历史保存因此一次都没跑过;这个写法 4 月修过,8 月又被一个说得通的理由改了回去。

目录

Python 的 async 生成器执行到 yield 会暂停,等消费方来要下一个值才继续。消费方 break 或者把生成器丢掉以后,asyncio 会调度 aclose(),往暂停的 yield 上抛 GeneratorExit:yield 之后的语句不执行,finally 会执行。所以放在最后一个 yield 后面的收尾工作,只有消费方把流读完才会跑。Autarkia 因为这个丢过对话历史,同一个写法修过一次又被改了回去。

Autarkia 的主对话是一个 async 生成器:一边从模型拿 token,一边往外 yield 事件,最后 yield 一个 {"done": True},客户端收到它才把这条消息显示完整。这轮对话要写进短期记忆,写库比较慢,所以放到后台任务里做。

问题出在这两件事的先后上。它前后被改了三次,每次都有理由。

01三次挪动

  • 4 月 28 日:保存原本是 await,排在 yield done 前面。首次写记忆要加载嵌入模型、初始化向量索引,大约 4 秒,完成信号被卡住,客户端那边看起来是「生成了但不显示」。修法是把 yield done 挪到前面,保存改成 create_task。
  • 4 月 29 日:从这以后每条消息都像新对话,历史一条都没存进去。修法是把 create_task 挪回 yield done 前面。create_task 不阻塞,所以 28 号的问题不会回来。旁边加了一句注释:必须在 yield done 之前调度。
  • 8 月 18 日:流式改造时有人觉得「完成之前就开始写库」不对,完成信号发出去才算结果确定,于是又挪到了 yield done 后面。那句注释还在原处。两天后,同样的症状回来了。

8 月那一版大致是这样:

python
yield {"done": True, "content": current_response_content}
asyncio.create_task(agent._save_conversation_history(**pending_save))

02生成器停在 yield 上

这两行读起来就是「先告诉客户端完成了,再在后台存」。但 async 生成器执行到 yield 会停住,要等消费方来要下一个值才继续往下走。done 是最后一个事件,消费方拿到它就 break,在 Autarkia 里转发流的编排层和 WebSocket 适配器都是这么写的。之后没有人再来要下一个值。

生成器对象被丢掉以后,asyncio 会替它调度 aclose(),往暂停的那个 yield 上抛一个 GeneratorExit。yield 后面的语句不会执行,finally 会。下面这段可以直接跑:

python
import asyncio, gc

async def gen_after():
    yield {"done": True}
    print("after yield")          # break 时不会打印

async def gen_finally():
    try:
        yield {"done": True}
    finally:
        print("finally")          # break 时也会打印

async def main():
    for g in (gen_after, gen_finally):
        async for ev in g():
            if ev.get("done"):
                break
        gc.collect()
        await asyncio.sleep(0.01)

asyncio.run(main())   # 只打印 finally

如果消费方把事件读完、不 break,yield 后面的代码是会执行的。这也是它难发现的原因之一。

03为什么两次都没当场发现

  • 写生成器的人看不到消费方。在生成器自己的文件里,最自然的想象是「事件被读完」,而这种情况下代码是对的。break 写在另外两个文件里。
  • 没有任何报错。aclose() 抛进去的 GeneratorExit 是正常的关闭流程,日志里什么都不会留下。
  • 症状是「AI 不记得上一句」,第一反应是去查记忆检索或者模型。主动发出的消息走另一条路径直接写库,不受影响,所以历史并不是完全空的,更像是检索出了问题。
  • 4 月那句注释只写了「必须」,没写为什么。8 月的改动者手里有一个更具体、听起来更对的理由,注释拦不住它,代码里也没有任何东西会因为挪了这两行而失败。

04修法

8 月 20 日改成把保存放进 yield done 的 finally。保存的参数在 yield 之前准备好,真正调度写库在 finally 里:消费方读完或者 break,两种情况都会走到。这样也满足 8 月那次改动想要的「完成信号之后才写库」。

python
try:
    yield {"done": True, "content": current_response_content}
finally:
    if pending_save:
        spawn_bg_task(agent._save_conversation_history(**pending_save))

注释也改了,写的是原因:消费方收到 done 会 break,生成器会被 aclose,yield 之后裸写的代码不会执行。同时加了一个验证脚本,覆盖读完、单层 break、两层转发后 break 三种消费方式,先用旧代码确认 break 的用例会失败,再验证新代码。

最近回头看还有一个尾巴:这个脚本叫 verify_history_save_not_lost.py,放在 tests/scripts/ 下面,而 pytest 只收集 test_*.py,CI 从来没跑过它。一个已经回归过一次的 bug,防它的测试不在 CI 里。我准备把它改写成 tests/unit/ 下的正式用例。

相关阅读