消费方拿到最后一个事件就 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 月那一版大致是这样:
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 会。下面这段可以直接跑:
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 月那次改动想要的「完成信号之后才写库」。
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/ 下的正式用例。