ContextVarに会話履歴を入れたら、asyncioの別タスクには混ざらない?
リストにappend()する設計なら混ざる。自分は今日、2会話だけのコードで確かめていて、両方の履歴に相手の質問が入った。ContextVarを使っているだけで、履歴を分離できた扱いにするのは危ないんだよね。
今回はタスクが継承したリストと、不変なタプルを比べる。送信直前に相当する履歴だけを調べるので、LLMや外部通信は使わない。混入する会話は2件から0件になった。
Q. どの操作で混ざる?
同じリストを引き継いだ子タスクで、get().append()したとき。
asyncio.create_task()は、通常は作成時のコンテキストをコピーする。ここで会話履歴まで深くコピーされる、と読むと詰まる。コンテキストが別でも、値として入っているリストは同じ実体を指す。
確認に使ったのはmacOSのCPython 3.14.8。標準ライブラリだけで動く。コードはasyncio.Barrierを使うためPython 3.11以降が対象だ。
Q. 2会話で再現できる?
次をhistory_check.pyに保存し、python3 history_check.pyで実行する。
import asyncio
from contextvars import ContextVar
history = ContextVar("history")
async def check(immutable):
token = history.set(() if immutable else [])
barrier = asyncio.Barrier(2)
async def chat(user):
message = f"{user}:質問"
if immutable:
history.set(history.get() + (message,))
else:
history.get().append(message)
await barrier.wait()
return tuple(history.get())
try:
tasks = [asyncio.create_task(chat(u)) for u in ("A", "B")]
results = await asyncio.gather(*tasks)
mixed = sum(
any(not m.startswith(u + ":") for m in row)
for u, row in zip(("A", "B"), results)
)
print("tuple" if immutable else "list", results, mixed)
return mixed
finally:
history.reset(token)
async def main():
assert await check(False) == 2
assert await check(True) == 0
asyncio.run(main())
手元の出力。末尾の数値は、相手の質問が1件以上入った会話数だ。
list [('A:質問', 'B:質問'), ('A:質問', 'B:質問')] 2
tuple [('A:質問',), ('B:質問',)] 0
Barrierで両タスクの追記を待ってから読む。待ち時間を調整して偶然混ぜる検証にはしていない。gather()の結果は渡したタスクの順なので、表示の1番目がA、2番目がBになる。
Q. setとappendで何が違う?
set()は、そのコンテキストで変数が指す値を替える。append()は、指しているリスト自体を書き換える。
今回のタプルは文字列だけを持つ。+ (message,)で新しいタプルを作り、各タスクでset()するので、相手の値を変更しない。タプルの中に辞書を入れ、その辞書を書き換えたら同じ問題が残る。外側だけタプルにすれば済む話でもない。
PythonのContext公式説明も、コピーを浅いコピーと明記している。実行環境に同梱されたasyncio/tasks.pyのTask参照実装では、既定の初期化でcontextvars.copy_context()を呼ぶところまで確認した。
Q. 実装ではどこを直す?
独立した会話を受け付ける入口で、履歴を初期化する。その後も値を更新するときは、新しい値をset()する。既存履歴を引き継ぐ子タスクでは、その継承が意図どおりかも確認したい。
末尾のカンマを付け忘れると、(message)は文字列になる。タプルの書き方、短いのに毎回ちょっと目が止まる。
今回は空の履歴から始めた。既存の履歴を継承した場合、タプルに替えても過去のメッセージは引き継ぐ。reset(token)も値の対応を戻す操作なので、共有リストへの追記を取り消してはくれない。
会話分離の確認には、2会話を同時に走らせ、送信直前の履歴を照合する小さな検証を残したい。今後ツール呼び出しを子タスクへ分けるほど、この確認の出番は増えると見ている。request IDと会話履歴では、ContextVarに入れる値の扱いも変わる。今回持ち帰るのは、履歴を追記した行で共有リストを書き換えていないか、という確認箇所だ。