资讯动态

Phoenix 中 OTel contextvars 与 async 边界的实战避坑指南:为何不在异步生成器清理路径里使用 contextvars

发布时间:2026/9/25 14:51:19 来源:尧图企业网站定制
可观测性AI 评测LLMOpsAI 应用人工智能【免费下载链接】phoenixAI Observability Evaluation项目地址https://gitcode.com/gh_mirrors/phoenix13/phoenix点击查看免费下载本文是 PhoenixAI Observability Evaluation 平台内部对OpenTelemetry 上下文contextvars跨异步边界失效问题的完整技术复盘聚焦 Playground 的聊天、流式输出与评估evaluators场景。读者读完将掌握async for 异常退出时token.reset()抛出Token was created in a different Context的 CPython/asyncio 根因、contextlib.aclosing与生成器侧修复两种方案的取舍以及 Phoenix 在 playground_clients.py 等处的最终设计。1. 问题陈述contextvars 跨 async 挂起/恢复会漂移在 Phoenix Playground 的流式链路中聊天补全、评估器、订阅subscriptions都依赖 OpenTelemetry 的start_as_current_span。该 API以及任何依赖contextvars的 API会把当前上下文存在一个 contextvar 中。一旦发生async 挂起/恢复例如async for chunk in stream()这个上下文就可能悄悄改变生成器侧异步生成器先cv.set(...)拿到 token然后yield当消费者抛异常或提前退出后生成器的finally可能在一个不同的上下文里执行。此时在finally中调用token.reset()会抛出Token was created in a different ContextOpenTelemetry 侧则表现为Failed to detach context。消费者侧async for遍历生成器的消费者也可能通过 contextvars 挂 span跨 async 边界时同样存在清理时上下文错位的风险。映射到 Phoenix 代码角色对应代码修复前是否使用 contextvars生成器chat_completion_create()playground_clients.py是隐式当前 span消费者evaluate()如 LLMEvaluatorevaluators.py是start_as_current_span核心目标有三用纯 Python 实证这个失败模式从 CPython 源码与官方文档层面搞清为什么设计出绝不依赖跨边界 contextvars 的架构。2. 根因CPython 与 asyncio 的行为2.1 观察到的现象异步生成器设置 contextvar 并拿到 token、yield之后如果消费者在async for期间抛异常生成器的finally会在另一个执行上下文中运行此时token.reset()抛出ValueError: Token ... was created in a different Context该校验逻辑位于 CPython 的Python/context.c// PyContextVar_Reset(): PyContext *ctx context_get(); // 当前上下文取自线程状态 if (ctx ! tok-tok_ctx) { // token 创建时的上下文set() 调用时 PyErr_Format(PyExc_ValueError, %R was created in a different Context, tok); return -1; }也就是说生成器的finally执行时其当前上下文与 token 创建时的上下文不是同一个。2.2 为什么async for在异常时不会调用aclose()相关源码Python/compile.ccompiler_async_for与Python/ceval.cEND_ASYNC_FOR。async for的结构为GET_AITER→ 循环体SETUP_FINALLY、GET_ANEXT、YIELD_FROM即 await、循环体→except 块END_ASYNC_FOR。整个流程中没有任何对iterator.aclose()的调用。END_ASYNC_FOR的行为如果异常是StopAsyncIteration弹出并继续对于任何其他异常包括循环体中的RuntimeError它只会重新抛出不会调用iterator.aclose()。于是异常发生后异步生成器保持打开状态直到被finalize例如被 GC 回收才真正关闭。2.3 生成器实际是如何关闭的finalizerLib/asyncio/base_events.py中注册了异步生成器终结钩子def _asyncgen_finalizer_hook(self, agen): self._asyncgens.discard(agen) if not self.is_closed(): self.call_soon_threadsafe(self.create_task, agen.aclose())事件循环在run_forever()中注册 async-gen 钩子后异步生成器被销毁时finalizer会通过新建 Task来调度agen.aclose()self.create_task(agen.aclose())创建了一个新Task每个Task在创建时捕获当时的上下文self._context contextvars.copy_context()Lib/asyncio/tasks.py因此执行aclose()的 Task 拿到的是 finalizer 运行时当前的上下文例如某个回调或事件循环默认上下文并非原消费者 Task 的上下文。该 Task 运行时通过context.run(callback)执行回调Lib/asyncio/events.py的Handle._run()于是生成器代码包括其finally跑在了这个新 Task 的上下文里。于是Token 创建于Context A原消费者 Task 中生成器执行cv.set()时生成器的finally运行于Context Bfinalizer 为执行aclose()新建的 Taskcontext_get()返回 B而tok-tok_ctx是 A → 抛出created in a different Context。一句话总结消费者异常 →async for不调用aclose()→ 生成器稍后被 finalize → finalizer 执行create_task(agen.aclose())→ 生成器的finally在一个上下文不同的新 Task 中运行。CPython 参考路径Python/context.cPyContextVar_Reset、context_get()、Python/compile.ccompiler_async_for、Python/ceval.cEND_ASYNC_FOR、Lib/asyncio/base_events.py_asyncgen_finalizer_hook、Lib/asyncio/tasks.pyTask 的copy_context()、Lib/asyncio/events.pyHandle._run()。3. 官方 Python 文档给出的两条出路异步生成器函数语言参考若异步生成器提前退出break、调用方取消或其他异常其异步清理会在意外的上下文中执行——例如在它依赖的 Task 生命周期结束之后或在事件循环关闭、触发异步生成器 GC 钩子时。调用方必须显式调用aclose()来终结生成器并把它从事件循环中摘除。contextlib.aclosing标准库使用async with aclosing(agen):可以保证生成器的异步退出代码在与迭代相同的上下文中执行异常与 contextvar 行为符合预期退出代码不会在某个它所依赖的 Task 生命周期结束后才运行。由此语言层面给出两种规避方式调用方修复显式关闭生成器例如async with aclosing(stream): ...生成器侧修复不在其清理代码中依赖 contextvars——在finally里用start_spanspan.end()而不是start_as_current_span。Phoenix 为流式客户端选择了方案 (2)这样就不必依赖每个消费者都记得使用aclosing()。4. 实证测试纯 Python不引入 OTel位置internal_docs/vignettes/otel-contextvars-async/contextvars_async_gen_demo.py运行方式仓库根目录uv run python internal_docs/vignettes/otel-contextvars-async/contextvars_async_gen_demo.py脚本刻意不引入 pytest只用 asyncio assert以避免测试框架引入干扰变量。脚本先运行若干 print 演示main()、main_normal()、main_consumer_raises()、main_consumer_with_sleep()最后执行run_empirical_tests()中的六组断言。4.1 四个核心场景场景结果结论生成器设置 token消费者不用aclosing 抛异常生成器reset(token)失败different Context普通async for 异常会把生成器留给 finalizer → 上下文错位生成器设置 token消费者用async with aclosing(gen):抛异常生成器reset(token)成功在同一 Task 中显式关闭清理保持在原上下文消费者设置 token生成器抛异常消费者reset(token)成功未复现消费者侧 token 失效evaluate 中避免 contextvars 属防御性设计正常退出无异常生成器reset(token)成功失败与异常/拆除路径绑定而非每次挂起关键结论该失败可复现且与 finalizer 强相关aclosing在直接消费者使用它时能实证修复消费者侧的 token 在这些场景中保持有效问题属于异常/拆除路径专属并非每次挂起都会发生。为什么消费者evaluate可以继续用start_as_current_span消费者是 async函数而非 async 生成器。当流抛异常或循环 break 时异常通过正常的栈展开传播消费者的with块在同一个 Task里退出因此它的 contextvar token 在上下文管理器执行__exit__时依然有效。场景 3、4 已实证确认消费者的reset(token)成功。所以 evaluators.py 中的evaluate可以放心使用start_as_current_span其清理在同一 Task 中运行。4.2aclosing能为我们做什么调用方侧修复如果消费者用async with aclosing(stream):包裹再async for chunk in stream: ...那么 break/raise/cancel 时上下文管理器会 awaitstream.aclose()生成器的finally在**同一 Task同一上下文**中运行生成器内的 contextvar token 依然有效。收益① 在同一 Task 中显式、及时清理② 若日后生成器再次引入上下文相关清理可保证同上下文③ 是官方文档记载的标准库模式。为何不依赖它Phoenix 有多个调用点evaluators、subscriptions、chat_mutations、playground_clients要求每个调用方都用aclosing()极易遗漏且对历史代码或第三方消费者无效。修复生成器本身清理时不使用 contextvarsfinally中用start_spanspan.end()能让流在任何调用方迭代方式下都安全。在自己的调用点再加aclosing作为可选加固。4.3 两种均有效的修复澄清只要在每个对流执行async for的调用点使用aclosing并不需要在生成器中放弃start_as_current_span。因为aclosing会让生成器由消费它的同一 Task 关闭其finally在同一上下文运行contextvar token 保持有效。两种修复任一即可修复做法效果调用方侧每个消费者都用async with aclosing(stream): async for ...生成器finally在同一 Task/上下文中运行 → 生成器内start_as_current_span安全生成器侧Phoenix 采用生成器在finally中用start_spanspan.end()而非start_as_current_span生成器清理不依赖当前上下文 → 无论调用方是否用aclosing都安全Phoenix 选择生成器侧修复使正确性不依赖于每个调用方包括未来代码与第三方代码都记得使用aclosing。4.4 为什么只在调用点加aclosing仍不够生成器链实践中 Phoenix 曾给所有调用chat_completion_create的位置subscriptions、chat_mutations、evaluators、playground_clients都加上aclosing(stream)但Token was created in a different Context依然出现。原因流的消费者往往本身就是一个async 生成器——例如订阅处理器执行async for chunk in stream: yield chunk把块转发给客户端。于是形成链条外层生成器 A订阅使用async with aclosing(stream B): async for chunk in B: yield chunk。当客户端断开或请求被放弃时驱动 A 的 Task 可能被取消A 本身也可能被丢弃。A 只会被finalizer关闭——finalizer 在新 Task中执行create_task(A.aclose())。该 Task 运行 A 的aclose()其中会退出 A 的async with aclosing(B)并调用B.aclose()。于是 B 是从 finalizer 的 Task 里被关闭的而非原请求 Task。B 的finally以及 OTel 的 detach跑在了错误的上下文中。aclosing(B)确实执行了但它是在A 的aclose()内部执行的而 A 的aclose()运行在 finalizer 的 Task 里。文档所述生成器 finally 与迭代同 Task只对直接迭代 B 的代码成立——那段代码是 A 的函数体当 A 自身被 finalize 时该函数体已不在原 Task 中运行。结论当直接调用点本身是一个可能被 finalizer 关闭的异步生成器时仅在直接调用点加aclosing不够。此时必须采用生成器侧修复B 的清理不依赖 contextvars。4.5 为什么框架栈Strawberry、Starlette、ASGI与此相关aclosing并非因为 Strawberry、Starlette 或 ASGI 层破坏而失效。Phoenix 确实正确地在流 (B) 周围使用了aclosing对流执行async for chunk in stream的代码是订阅 resolver它运行了async with aclosing(stream): ...。微妙之处在上一层流的消费者是订阅 resolver——一个异步生成器 (A)把块 yield 给客户端。真正迭代该 resolver 的是框架Strawberry 的订阅传输构建于 Starlette/ASGI 之上。客户端断开或请求结束时该层通常不会对订阅 resolver 调用aclosingresolver 被丢弃后稍后被 finalize。于是我们用 aclosing 关闭流 (B)迭代 B 的代码是 resolver (A)框架不会用 aclosing 关闭 resolver (A)而是把它留给 GC 与 finalizerfinalizer 在新 Task 中执行A.aclose()这会运行我们写在aclosing(B).__aexit__里的逻辑于是 B 在错误的上下文中被关闭。所以底层栈之所以相关不是因为它破坏 aclosing而是因为它定义了谁在迭代订阅并且不保证该迭代器resolver在同一 Task 中被关闭。resolver 常常留给 finalizer导致流最终从 finalizer 的 Task 里被关闭。因此无论框架如何关闭或不关闭订阅生成器侧修复B 的清理不使用 contextvars都是必需的。5. Phoenix 源码中的落地实现5.1 生成器侧chat_completion_create用start_spanfinally里的span.end()在 playground_clients.py 中chat_completion_create的实现约第 191–240 行正是生成器侧修复的直接体现# 使用 start_span而非 start_as_current_span并在 finally 中 span.end() # 使生成器绝不挂接 contextvars。可避免生成器在另一 Task 中被关闭时的 # Failed to detach context / Token was created in a different Context。 span tracer_.start_span( ChatCompletion, contextotel_context, attributesattributes, set_status_on_exceptionFalse, # 状态手动设置 ) try: async for chunk in self._chat_completion_create( messagesmessages, toolstools, response_formatresponse_format, invocation_parametersinvocation_parameters, spanspan, stream_model_outputstream_model_output, ): yield chunk span.set_status(Status(StatusCode.OK)) except Exception as e: span.set_status(Status(StatusCode.ERROR, str(e))) span.record_exception(e) raise finally: span.end()要点span 由start_span创建并不设为当前 span异常时手动设置ERROR状态并record_exception最终在finally里无条件span.end()。因为span.end()不触碰任何 contextvar即使生成器的finally在 finalizer 的另一个 Task 上下文中执行也不会出错。源码注释明确记录了这条设计原则及其针对的两个报错。5.2 消费者侧evaluators.py保持start_as_current_span在 evaluators.py 中评估器大量使用tracer_.start_as_current_span(...)例如约第 314、327、358、418 行等。这与 §4.1 的结论一致evaluate是 async函数而非异步生成器异常沿栈展开时其with块在同一 Task 中退出token 始终有效因此这里保留 contextvars 是安全的。5.3 订阅链路subscriptions.py的流式转发在 subscriptions.py 中订阅 resolver 约第 105–115 行对llm_client.chat_completion_create(...)执行async for chunk in ...后yield chunk并在异常时 yield 一个ChatCompletionSubscriptionError。这正对应 §4.4 的链式生成器resolver 本身是 async 生成器可能被框架留给 finalizer。此时chat_completion_create内部的生成器侧修复保证流的清理安全而框架是否对 resolver 调用aclosing已无关紧要。5.4 应用层测试验证test_playground_clients.py 中覆盖了客户端行为与 span 属性的断言如test_text_response_records_expected_attributes、test_authentication_error_records_error_status_on_span等用于验证生成器侧修复后产生的 trace 符合预期且不再出现Failed to detach context或 token 相关错误。6. 测试策略先用纯 Python只用contextvars 异步生成器不引入 OTel证明 token 在挂起 异常后可能失效产出可泛化的 Python 语言层知识映射到 Phoenix 架构生成器 流式 LLM 客户端消费者 评估器或订阅处理器。设计上让生成器绝不使用start_as_current_span改用start_span 在finally中手动span.end()使清理不触碰 contextvars应用层测试现有测试如test_playground_clients.py、评估器测试、chat_mutations/subscriptions 全链路带 trace 的测试验证生成器侧修复能产出预期的 trace且无 token 错误。7. 设计决策总结组件决策理由playground_clients.pychat_completion_create使用tracer.start_span(...)在finally中span.end()不用start_as_current_span已实证生成器的 contextvar token 在另一个 Task 中关闭时可能在finally中失效避免在生成器中挂接 contextvarsevaluators.py照常使用start_as_current_spanevaluate 是 async 函数而非异步生成器其清理在同一 Task 中运行消费者 token 保持有效见 §4.1evaluate 无生成器侧风险subscriptions.py / chat_mutations.py在消费流的调用点使用 aclosingchat_completion_create的生成器侧修复让流即使订阅 resolver 被 finalize 也安全纵深防御生成器清理不依赖 contextvars8. 参考资源Phoenix 代码playground_clients.py、evaluators.py、subscriptions.py、chat_mutations.py通用演示脚本contextvars_async_gen_demo.py仓库根目录运行uv run python internal_docs/vignettes/otel-contextvars-async/contextvars_async_gen_demo.py应用层测试test_playground_clients.py以及test_evaluators.py与完整 chat/subscriptions 链路带 trace的测试CPython 源码路径Python/context.c、Python/compile.c、Python/ceval.c、Lib/asyncio/base_events.py、Lib/asyncio/tasks.py、Lib/asyncio/events.py官方文档《异步生成器函数》语言参考与《contextlib.aclosing》标准库中关于异步生成器提前退出后清理上下文错位的说明赞分享可观测性AI 评测LLMOpsAI 应用人工智能【免费下载链接】phoenixAI Observability Evaluation项目地址https://gitcode.com/gh_mirrors/phoenix13/phoenix点击查看免费下载相关推荐Phoenix 实战OTel Context 与异步边界——为何在 async 生成器清理中规避 contextvarsPhoenix 实战OTel Context 与异步边界——为何在 async 生成器清理中规避 contextvars 导读 本文是 Phoenix 项目中可观测性AI 评测LLMOpsAI 应用人工智能Phoenix OTEL 指南用 arize-phoenix-otel 为 Phoenix 配置 OpenTelemetry 追踪Phoenix OTEL 指南用 arize phoenix otel 为 Phoenix 配置 OpenTelemetry 追踪 arize phoenix可观测性AI 评测LLMOpsAI 应用人工智能gRPC Python 异步拦截器与 contextvars 上下文传递grpc/grpc 仓库 async interceptor 示例深度解析gRPC Python 异步拦截器与 contextvars 上下文传递grpc/grpc 仓库 async interceptor 示例深度解析 本文以 e后端RPC框架微服务通信上一篇3步解锁PS4游戏新境界GoldHEN Cheats Manager完全掌控指南下一篇3DS FBI LinkMac上最便捷的3DS文件传输工具终极指南创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑