مقالات

طراحی معماری حافظه برای عوامل هوش مصنوعی دولتی


مدل زبان چیزی بین تماس ها ندارد. هر درخواست بدون خاطره ای از درخواست قبلی می رسد و مدل از درخواست قبل از آن پاسخ می دهد و هیچ چیز دیگری. برای یک بار سوال خوب است. برای عاملی که فرد در طول روزها، رشته ها و وظایف فردی به آن باز می گردد، این محدودیت طراحی تعیین کننده می شود. رفتاری که کاربران از یک نماینده انتظار دارند، به خاطر سپردن آنچه در هفته گذشته گفته اند، از سرگیری کاری که متوقف کرده اند، اعمال ترجیحات اعلام شده خود بدون درخواست مجدد، دقیقاً رفتاری است که یک مدل بدون حالت نمی تواند به تنهایی ایجاد کند. مدل زبان بزرگ (LLM) زمینه شما را فراموش نمی کند زیرا انتخاب کرده است. هرگز نبود.

تصحیح رفلکس برای بازگرداندن داستان است. یک بافر از حرکات قبلی نگه دارید و آن را قبل از هر فرمان اضافه کنید تا عامل به نظر به یاد بیاورد. این توهم از یک نمایش کوتاه و وقفه های تولید جان سالم به در برد. ساخت عوامل هوش مصنوعی حالت دار که تداوم بین جلسات را حفظ می کنند، نیازمند معماری حافظه است، نه بافر بزرگتر.

چرا الگوهای بدون وضعیت، عوامل چند جلسه ای را می شکند؟

رویکرد بافر تماس به سه روش قابل اندازه گیری شکست می خورد و پیچیده می شوند. اولین مورد قیمت است. هر حرکت قبلی که تکرار می‌کنید دوباره در تماس بعدی شارژ می‌شود، بنابراین بافری که به صورت خطی رشد می‌کند، یک جلسه پنجاه حرکتی را به پنجاه تکرار از همان نشانه‌های شروع تبدیل می‌کند. دوم تأخیر است. پیام‌های بزرگ‌تر برای ارسال و پردازش بیشتر طول می‌کشد، و این پیشنهاد در هر نوبت، نه فقط در موارد طولانی، انجام می‌شود. سومین موردی است که تیم ها را غافلگیر می کند: با افزایش درخواست ها، فراخوان ادامه پیدا نمی کند. تحقیقات در مورد رفتار با زمینه طولانی، به ویژه لیو و همکارانش در سال 2023، نشان داد که مدل‌ها بیشترین اطمینان را به اطلاعات در ابتدا و انتهای یک دستور دارند و کمترین اطمینان را نسبت به آنچه در وسط قرار دارد، دارند. یک دستورالعمل مهم را در نوبت نهم از رونوشت چهل نوبتی دفن کنید، و مدل می تواند طوری عمل کند که انگار آنجا نیست.

خط سخت تری در زیر همه وجود دارد. هر مدل دارای یک پنجره زمینه ثابت است که معمولاً بین 8000 تا 128000 توکن بسته به مدل دارد. از آن عبور کنید، و ارائه‌دهنده یا درخواست را با یک خطای طول زمینه رد می‌کند، یا بی‌صدا قدیمی‌ترین حرکت‌ها را کوتاه می‌کند، به این معنی که عامل بافت اولیه‌ای را که بافر قرار بود حفظ کند، از دست می‌دهد. هیچکدام از اینها به موضوع جلسات متقابل نمی پردازد. بافر در یک جلسه زندگی می کند. فردا یک تاپیک جدید باز کنید و بافر دوباره خالی است. اولویت‌ها و حقایقی که کاربر دیروز ایجاد کرده بود، از بین رفته‌اند، زیرا هیچ چیز آنها را در مکانی که بیشتر از جلسه است بازیابی نکرده است.

بنابراین رویکرد استاندارد مقیاس‌پذیر نیست و دلیل آن این است که دو وظیفه متفاوت را با هم ترکیب می‌کند: حفظ مکالمه فوری و به خاطر سپردن مکالمات. اینها مشکلات جداگانه ای با زندگی های مختلف هستند و به ماشین های جداگانه نیاز دارند.

اندازه گیری فشار زمینه در یک عامل زنده

قبل از طراحی هر چیزی، آنچه را که از قبل دارید، ابزار کنید. حالت‌های خرابی بالا تا زمانی که مصرف توکن را در هر نوبت با پنجره مدل اندازه‌گیری نکنید، نامرئی می‌مانند، و بیشتر تیم‌ها تنها زمانی سقف را کشف می‌کنند که یک درخواست یک خطای مشتری باشد. ابزار کوچکی که توکن‌ها را با رشد جلسه ردیابی می‌کند، یک حادثه تولید را به یک هشدار ثبت‌شده تبدیل می‌کند.

# context_budget.py
# Instruments an agent's session log so you can see context pressure build
# up turn by turn, instead of discovering it when a request silently truncates
# or the provider returns a context-length error mid-conversation.

from dataclasses import dataclass, field

# In production, count with the model's real tokenizer (for example tiktoken's
# cl100k_base or the o200k_base encoding). We wrap it so a missing encoding or
# offline build degrades to a conservative heuristic instead of crashing the
# request path, which is what you actually want in a hot loop.
def count_tokens(text: str) -> int:
    try:
        import tiktoken
        return len(tiktoken.get_encoding("o200k_base").encode(text))
    except Exception:
        # ~4 characters per token is a safe upper-bound heuristic for English.
        return (len(text) + 3) // 4

@dataclass
class SessionLog:
    budget: int                       # hard ceiling for prompt tokens
    events: list = field(default_factory=list)

    def add(self, role: str, text: str) -> None:
        self.events.append((role, text, count_tokens(text)))

    def used(self) -> int:
        return sum(t for _, _, t in self.events)

    def pressure(self) -> float:
        # Fraction of the budget consumed. Alert teams above ~0.75 so there is
        # headroom for the model's own output tokens, not just the prompt.
        return self.used() / self.budget

log = SessionLog(budget=2000)
for turn in range(1, 13):
    log.add("user", "Walk me through the failure we saw in the nightly run. " * turn)
    log.add("assistant", "Here is the breakdown of the root cause and the fix. " * turn)
    if log.pressure() > 0.75:
        print(f"turn {turn:>2}: used={log.used():>5} tokens  "
              f"pressure={log.pressure():.0%}  ACTION: evict or summarize")
    else:
        print(f"turn {turn:>2}: used={log.used():>5} tokens  pressure={log.pressure():.0%}")

آستانه مهم است. هشدار در هفتاد و پنج درصد پنجره به جای صد، فضایی را برای نشانه‌های خروجی خود مدل از همان بودجه ایجاد می‌کند. توکن‌ها را با توکنایزر واقعی مدل بشمارید، زیرا کتابخانه tiktoken کدگذاری‌های o200k_base و cl100k_base را که توسط مدل‌های فعلی استفاده می‌شود، نشان می‌دهد، زیرا امتیاز فضای خالی می‌تواند با حاشیه زیادی از کد، نشانه‌گذاری یا متن غیرانگلیسی کاهش یابد. بازگشت اکتشافی در wrapper وجود دارد به طوری که مسیر پرس و جو به جای پرتاب زمانی که کدگذاری در دسترس نیست، به یک ارزیابی تنزل داده می شود. هنگامی که می توانید افزایش فشار را مشاهده کنید، مورد برای لایه حافظه دوم نظری نیست.

معماری حافظه دو لایه برای عوامل هوش مصنوعی Stateful

معماری ای که پشتیبانی می کند حافظه مادام العمر را از هم جدا می کند. حافظه کوتاه‌مدت گزارش خام رویدادهای جلسه جاری است: هر حرکت کاربر و پاسخ عامل به‌طور ارزان اضافه می‌شود، توسط یک تعامل پوشش داده می‌شود، بایگانی می‌شود یا با پایان جلسه کنار گذاشته می‌شود. حافظه بلندمدت باقیمانده مقطر، حقایق، ترجیحات، و تعمیم هایی است که ارزش نگهداری دارند که از رویدادهای خام استخراج شده و در هر جلسه آینده حفظ می شود. حافظه کوتاه مدت به آنچه که در آن مکالمه اتفاق افتاده پاسخ می دهد. حافظه بلندمدت با آنچه سیستم در مورد آن کاربر می داند مطابقت دارد. هر دو را در یک فروشگاه نگه دارید و یا با یک رونوشت خام پیام را پر می‌کنید یا حقایق ثابت را در انبار از دست خواهید داد.

حافظه بلندمدت یک زیرساخت مشترک است که اغلب بین بسیاری از مستاجران است، بنابراین اولین راه حل ساختاری آدرس دهی است. ورودی ها با یک فضای نام سلسله مراتبی به جای یک شناسه مسطح کلید می خورند و خود سلسله مراتب جداسازی را اعمال می کند. بازیابی با محدوده یک کلاینت نمی تواند کلیدی را که به مشتری دیگر تعلق دارد حل کند زیرا شناسه کلاینت پیشوند ساختاری هر کلید است، نه فیلتری که پس از بازیابی اعمال می شود.

# namespaces.py
# Memory records are addressed by a hierarchical namespace, not a flat key.
# The hierarchy is what enforces multi-tenant isolation: a retrieval scoped to
# one tenant can never resolve a key that belongs to another, because the
# tenant id is a structural prefix of every key rather than a filter applied
# after the fetch.

import re

# Components are validated so a caller cannot smuggle a separator into an id and
# escape its own subtree. This is the single most common isolation bug in
# home-grown memory stores: an unescaped "https://dzone.com/" in a user id reads as a new level.
_SAFE = re.compile(r"^[A-Za-z0-9_-]+$")

def make_namespace(tenant: str, actor: str, scope: str) -> str:
    for part in (tenant, actor, scope):
        if not _SAFE.match(part):
            raise ValueError(f"unsafe namespace component: {part!r}")
    return f"/{tenant}/{actor}/{scope}"

class MemoryStore:
    def __init__(self):
        self._records: dict[str, list[str]] = {}

    def put(self, namespace: str, value: str) -> None:
        self._records.setdefault(namespace, []).append(value)

    def query(self, namespace_prefix: str) -> list[str]:
        # Only keys under the caller's prefix are visible. The prefix check is
        # the isolation boundary, so it runs before any ranking or scoring.
        hits = []
        for key, values in self._records.items():
            if key == namespace_prefix or key.startswith(namespace_prefix + "https://dzone.com/"):
                hits.extend(values)
        return hits

store = MemoryStore()
store.put(make_namespace("tenant-a", "user-42", "preferences"), "prefers metric units")
store.put(make_namespace("tenant-a", "user-42", "facts"), "deploys to two regions")
store.put(make_namespace("tenant-b", "user-42", "preferences"), "prefers imperial units")

print("tenant-a/user-42 sees:", store.query("/tenant-a/user-42"))
print("tenant-b/user-42 sees:", store.query("/tenant-b/user-42"))

# A caller trying to break out of its subtree by embedding a separator is
# rejected at construction time, before any record is written or read.
try:
    make_namespace("tenant-a", "user-42/../tenant-b", "preferences")
except ValueError as exc:
    print("isolation guard:", exc)

اعتبار سنجی تزئین نیست. رایج‌ترین نقص جداسازی در یک ذخیره‌سازی حافظه بومی، یک جداکننده بدون فرار در یک شناسه ارائه‌شده توسط کاربر است، که در آن یک مقدار قالب‌بندی‌شده به‌عنوان قطعه مسیر به‌عنوان یک سطح فضای نام جدید خوانده می‌شود و به تماس‌گیرنده اجازه می‌دهد تا از زیردرخت خودش خارج شود. رد کردن اجزای ناامن در زمان ساخت این حفره را قبل از نوشتن یا خواندن یک رکورد بسته می‌کند.

افزودن رویدادهای خام ارزان است و جستجو برای آنها تقریباً بی فایده است. تبدیل آنها به دانش قابل استخراج کار یک خط لوله استخراج است و آن خط لوله خارج از مسیر پرس و جو قرار دارد. تقطیر یک مدل را فراخوانی می‌کند و صدها میلی‌ثانیه هزینه دارد، بنابراین اجرای آن به صورت درون خطی هر نوبت را با کاری که کاربر هرگز نمی‌بیند، سنگین می‌کند. یک کارگر ناهمزمان دسته‌هایی از رویدادهای خام را می‌خواند و سوابق بلندمدت متعهد را بازنویسی می‌کند.

# extraction_worker.py
# Raw session events are cheap to append and useless to search. The extraction
# worker runs off the request path, reads batches of raw events and distills
# them into durable long-term records: facts, preferences and rolling
# summaries. Keeping this asynchronous is the point. Distillation calls a model
# and takes hundreds of milliseconds, so doing it inline would tax every turn.

import queue
import threading
import time

raw_events: "queue.Queue[dict]" = queue.Queue()
long_term: list[dict] = []

def distill(batch: list[dict]) -> list[dict]:
    # PLACEHOLDER for the model call. In production this is a structured-output
    # request to an extraction model that returns typed records. The rule-based
    # stub below keeps the worker deterministic and runnable for tests; swap it
    # for the real call and keep the same return contract.
    records = []
    for ev in batch:
        text = ev["text"].lower()
        if "prefer" in text:
            records.append({"type": "preference", "value": ev["text"], "src": ev["id"]})
        elif any(k in text for k in ("region", "deploy", "cluster", "schema")):
            records.append({"type": "fact", "value": ev["text"], "src": ev["id"]})
    return records

def worker(batch_size: int = 4, idle_flush: float = 0.2) -> None:
    batch: list[dict] = []
    last = time.monotonic()
    while True:
        try:
            ev = raw_events.get(timeout=idle_flush)
            if ev is None:                      # poison pill: drain then stop
                if batch:
                    long_term.extend(distill(batch))
                break
            batch.append(ev)
        except queue.Empty:
            pass
        # Flush on a full batch or after an idle gap, so a quiet session still
        # gets its memory persisted instead of waiting for more traffic.
        if len(batch) >= batch_size or (batch and time.monotonic() - last > idle_flush):
            long_term.extend(distill(batch))
            batch, last = [], time.monotonic()

t = threading.Thread(target=worker, daemon=True)
t.start()
for i, msg in enumerate([
    "I prefer metric units in reports",
    "we deploy to two regions",
    "the order schema changed last week",
    "thanks, that helps",
    "I prefer dark themed dashboards",
]):
    raw_events.put({"id": f"e{i}", "text": msg})
raw_events.put(None)
t.join(timeout=2)
for rec in long_term:
    print(rec)

دو جزئیات در این کارگر جای خود را به دست می آورند. بسته زمانی که اندازه فعال می‌شود یا مهلت زمانی منقضی می‌شود، پاک می‌شود، بنابراین جلسه بی‌صدا به جای انتظار برای ترافیکی که هرگز نمی‌رسد، همچنان حافظه خود را حفظ می‌کند. مسیر تخلیه خاموش حتی اهمیت بیشتری دارد: نسخه قبلی که قرص سم را بدون پاک کردن قسمت جزئی از بین می‌برد، هر بار که کارگر متوقف می‌شد، بی‌صدا چند رویداد آخر را رها می‌کرد، نوعی خطای ثبت گمشده که هفته‌ها بعد به‌عنوان خاطرات گم شده نشان می‌دهد. فراخوانی مدل در اینجا با قوانین قطعی پوشش داده می شود، بنابراین کارگر قابل آزمایش باقی می ماند. عقد معناست. برای استخراج خروجی ساختاریافته، خرد تماس را عوض کنید و بقیه باقی می ماند.

با قرار گرفتن هر دو لایه، یک حرکت عامل از هر یک خوانده می‌شود، یک درخواست در بودجه توکن جمع‌آوری می‌کند و رویداد جدید را برای بازیابی بعدی ذخیره می‌کند.

# agent_turn.py
# One agent turn that reads from both memory layers and respects a token
# budget. Long-term records come first because they are dense and stable;
# recent raw turns fill whatever budget remains. This ordering matters: models
# attend less reliably to the middle of a long prompt, so the highest-value
# memory belongs near the edges, not buried in the transcript.

def count_tokens(text: str) -> int:
    return (len(text) + 3) // 4   # heuristic; production swaps in the real tokenizer

def assemble_context(long_term: list[str], recent: list[str], budget: int) -> list[str]:
    context, used = [], 0
    # Long-term memory is admitted first, up to half the budget, so a long
    # transcript can never crowd out durable facts the agent depends on.
    for rec in long_term:
        cost = count_tokens(rec)
        if used + cost > budget // 2:
            break
        context.append(rec)
        used += cost
    # Recent turns fill the remainder, newest first, dropping the oldest.
    for turn in reversed(recent):
        cost = count_tokens(turn)
        if used + cost > budget:
            break
        context.append(turn)
        used += cost
    return context

def run_turn(user_msg, long_term, recent, budget=400):
    # Reserve room for the user message before admitting any memory, otherwise
    # the assembled prompt overshoots the budget once the message is appended.
    reserve = count_tokens(f"user: {user_msg}") + 1
    context = assemble_context(long_term, recent, budget - reserve)
    prompt = "\n".join(context + [f"user: {user_msg}"])
    # PLACEHOLDER for the model call. The returned context window is what the
    # agent actually reasons over; assert it before spending tokens on inference.
    assert count_tokens(prompt) 

سفارش دادن یک انتخاب آگاهانه است. سوابق بلندمدت اول می‌آیند و به نصف بودجه محدود می‌شوند، بنابراین یک رونوشت طولانی هرگز نمی‌تواند حقایق پایداری را که یک نماینده به آن وابسته است، جابجا کند. سپس آخرین پیچ ها بقیه را پر می کنند، ابتدا جدیدترین. از آنجایی که الگوها با کمترین اطمینان از وسط فرمان بازدید می کنند، بیشترین مقدار حافظه به لبه ها تعلق دارد. رزرو فضا برای پیام کاربر قبل از مجاز شدن هر حافظه، از سرریز شدن پیام مونتاژ شده پس از افزودن پیام جلوگیری می کند، خطایی که صورت بودجه در لحظه لغزش آن را تشخیص می دهد.

معاوضه در زمان بازیابی، بازیابی و ثبات

هر تصمیمی در این طرح، یک ملک را به قیمت ملک دیگری می‌خرد، و تظاهر به غیر این است که تیم‌ها بعداً خودشان را می‌گیرند. بازیابی ناهمزمان تأخیر صف را حفظ می‌کند، اما پنجره‌ای را برای ثبات نهایی معرفی می‌کند: در چند ثانیه بین یک رویداد و تقطیر آن، لایه طولانی‌مدت هنوز نمی‌داند کاربر چه چیزی گفته است. برای اکثر دستیاران، این تاخیر نامرئی است. برای گردش کاری که کاربر یک واقعیت را بیان می کند و بلافاصله انتظار دارد که عامل در سراسر مرز جلسه بر روی آن عمل کند، یا پنجره را کوتاه می کنید یا گزارش کوتاه مدت جلسه جاری را مطالعه می کنید. استخراج همزمان تاخیر را حذف می کند و در عوض آن صدها میلی ثانیه را به هر حرکت اضافه می کند. تجارت خود را عاقلانه انتخاب کنید.

استخراج تنش های خاص خود را به همراه دارد. کاندیداها تنها بر اساس شباهت معنایی ارزیابی می‌شوند و یک ورودی قدیمی می‌تواند برنده شود، زیرا اولویتی که کاربر ماه‌ها پیش تعیین کرده و پس از آن معکوس شده است، هنوز دقیقاً با جاسازی پرس و جو مطابقت دارد. یک افت تازگی را در امتیاز مخلوط کنید و تازگی یک رای می گیرد.

# ranking.py
# Retrieval that scores only on semantic similarity will happily surface a
# year-old preference the user has since reversed. Blending a recency decay
# into the score fixes that, but the decay rate is a real tuning knob: too
# aggressive and the agent forgets stable facts, too gentle and stale memory
# wins. This compares pure similarity against a recency-weighted score on the
# same candidate set so the trade-off is visible.

import numpy as np

# query vector and candidate vectors stand in for embeddings; in production
# these come from an embedding model and a vector index, but the ranking math
# is identical regardless of where the vectors originate.
query = np.array([0.9, 0.1, 0.0])
candidates = [
    {"text": "prefers metric units",   "vec": np.array([0.88, 0.12, 0.0]), "age_days": 210},
    {"text": "prefers imperial units",  "vec": np.array([0.86, 0.14, 0.0]), "age_days": 3},
    {"text": "works on payments",        "vec": np.array([0.20, 0.80, 0.1]), "age_days": 12},
]

def cosine(a, b):
    return float(a @ b / (np.linalg.norm(a) * np.linalg.norm(b)))

def rank(decay_per_day: float):
    scored = []
    for c in candidates:
        sim = cosine(query, c["vec"])
        # Exponential recency decay. A fresher record with slightly lower
        # similarity can now outrank a near-duplicate that is months old.
        recency = np.exp(-decay_per_day * c["age_days"])
        blended = sim if decay_per_day == 0 else 0.6 * sim + 0.4 * recency
        scored.append((round(float(blended), 3), c["text"]))
    return sorted(scored, reverse=True)

print("pure semantic:        ", rank(decay_per_day=0.0)[0])
print("recency-weighted 0.01:", rank(decay_per_day=0.01)[0])
print("recency-weighted 0.05:", rank(decay_per_day=0.05)[0])

نرخ پوسیدگی یک دکمه واقعی است، نه یک پیش فرض که باید نادیده گرفته شود. بیش از حد تهاجمی است و عامل حقایق محکمی را که باید ماه ها حفظ شود را فراموش می کند. حافظه بسیار ظریف و قدیمی همچنان ظاهر می شود. در نتیجه توجه کنید که چگونه رکوردی که سه روز از عمر آن می گذرد، رکوردی را که ماه ها از عمر آن گذشته است، اصلاً وزن دهی می شود، حتی اگر رکورد قدیمی تر به تنهایی از نظر شباهت، امتیاز کمی بالاتر داشته باشد. ثابت صحیح بستگی به سرعت تغییر واقعیات دامنه شما دارد، چیزی که فقط افرادی که سیستم را اجرا می کنند می توانند با ترافیک واقعی کالیبره کنند.

همچنین هزینه کمتری برای کل رویکرد وجود دارد. استخراج و جاسازی رایگان نیست، زیرا هر رویدادی که برای تقطیر و فهرست بندی انتخاب می کنید، محاسبات و ذخیره سازی را مصرف می کند، بنابراین فهرست کردن همه چیز به ندرت راه حل مناسبی است. بازیابی انتخابی، صرفه جویی در حقایق، ترجیحات و خلاصه های دوره ای به جای گپ خام، فروشگاه درازمدت را به اندازه کافی کوچک نگه می دارد تا سریع و به اندازه کافی ارزان باشد تا خود را توجیه کند. تیم هایی که از دو لایه صرف نظر می کنند از این هزینه اجتناب نمی کنند. آنها هزینه آن را در یک تجربه کاربری ضعیف می پردازند: عوامل تکراری که دوباره همان اطلاعات را می خواهند، حباب را تا زمانی که مرتکب اشتباه شوند تحریک می کنند، و حافظه ای که در لحظه بسته شدن جلسه تبخیر می شود. جدا کردن زمینه کوتاه مدت از ماندگاری بلندمدت چیزی است که یک مدل بدون تابعیت را به عاملی تبدیل می کند که به گونه ای رفتار می کند که گویی به یاد می آورد، و زمانی که دیگر انتظار ندارید مدل آن را برای شما حل کند، یک مشکل ساده مهندسی نرم افزار باقی می ماند.


Source link

نوشته های مشابه

دیدگاهتان را بنویسید

دکمه بازگشت به بالا