0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ContextVarで履歴を分離、会話の混入が2件から0件に

0
Posted at

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に入れる値の扱いも変わる。今回持ち帰るのは、履歴を追記した行で共有リストを書き換えていないか、という確認箇所だ。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?