Knaph

حلقه13 از 376 دقیقه مطالعه

حلقه در بیست خط

لیلا از mini می‌پرسد «کدام یادداشتم اسمِ امید را دارد؟». mini فایلِ notes.txt را می‌خواند، چیزی پیدا نمی‌کند و جواب می‌دهد «پیدایش نکردم.» هیچ وقت سراغِ todo.txt نرفت، چون mini فقط یک دور ابزار اجازه می‌دهد: بعد از اولین نتیجه، مدل باید جواب بدهد. یک آدم فایلِ بعدی را هم نگاه می‌کرد. این درس به mini اجازه می‌دهد همین کار را بکند، و حدودِ بیست خط کد لازم دارد.


لیلا پس آن تکه‌ی «اگر ابزار خواست، اجرایشان کن و دوباره بپرس» را کپی می‌کنم و چند بارِ دیگر می‌چسبانم؟

امید چند بار؟

لیلا نمی‌دانم. به اندازه‌ای که کار لازم دارد. که از قبل نمی‌توانم بدانم... پس باید حلقه باشد. تا وقتی مدل ابزار می‌خواهد، ادامه می‌دهد.

امید و کی می‌ایستد؟

لیلا وقتی پاسخی هیچ فراخوانیِ ابزاری نداشته باشد. آن وقت همان پاسخ جواب است.

امید آیا «ابزار خواست» یا «نخواست» تنها چیزی است که یک پاسخ می‌تواند به تو بگوید؟

لیلا stop_reason هم هست. وقتی ابزار می‌خواهد "tool_use" است. وقتی کارش تمام شده، لابد مقدارِ دیگری دارد.

امید "end_turn". ولی مقدارهای دیگری هم هست. اگر مدل وسطِ نوشتنِ یک فراخوانیِ ابزار به سقفِ max_tokens بخورد و نوشتنش قطع شود چه؟

لیلا آن وقت ممکن است ورودی نصفه نوشته شده باشد. نصفِ اسمِ یک فایل، نصفِ یک فرمان... نباید اجرایش کنم.

امید نه. و یکی دیگر: اگر مدل هیچ وقت از خواستن دست نکشد چه؟

لیلا آن وقت حلقه‌ام تا ابد می‌چرخد و پولِ همه‌ی نوبت‌ها را می‌دهم. پس یک سقف هم لازم دارم.


حلقه

این حلقه‌ی عاملِ mini است، run_agent. کار را می‌گیرد، اسمِ ابزارهایی را که این عامل اجازه دارد به کار ببرد، و پرامپت سیستمی را:

MAX_TURNS = 40
 
def run_agent(task, tool_names, system, max_turns=MAX_TURNS):
    messages = [{"role": "user", "content": task}]
    tools = tool_schemas(tool_names)
    for turn in range(max_turns):
        response = client.messages.create(model=MODEL, max_tokens=16000, system=system,
                                          tools=tools, messages=messages)
        messages.append({"role": "assistant", "content": response.content})
        if response.stop_reason == "end_turn":
            return "".join(b.text for b in response.content if b.type == "text")
        if response.stop_reason != "tool_use":  # max_tokens, refusal: don't run a cut-off call
            return f"stopped: {response.stop_reason}"
        calls = [b for b in response.content if b.type == "tool_use"]
        messages.append({"role": "user", "content": [run_tool(b, tool_names) for b in calls]})
    return f"stopped: no answer after {max_turns} turns"
Python

آن را همان چرخه‌ای بخوانید که از حلقه‌ی عامل می‌شناسید:

  1. گفت‌وگو را با کار شروع کن.
  2. در هر نوبت: همه چیز را بفرست، و کلِ پاسخِ مدل را به گفت‌وگو اضافه کن.
  3. اگر کارِ مدل تمام شده، حرفش را برگردان: بلوک‌های متن، پشتِ هم، بدونِ فکر کردن.
  4. اگر به هر دلیلی جز خواستنِ ابزار ایستاده، تو هم بایست و بگو چرا.
  5. وگرنه همه‌ی فراخوانی‌های ابزار را اجرا کن، یک پیامِ user پر از نتیجه‌ها اضافه کن، و دوباره بچرخ.
  6. اگر نوبت‌ها تمام شد، همین را بگو.

حلقه‌ی عامل به شکلِ یک نمودار. درخواست فرستاده می‌شود، پاسخ افزوده می‌شود، و بعد بسته به stop_reason راه دو یا سه شاخه می‌شود. end_turn به برگرداندنِ پاسخ می‌رسد. tool_use به اجرای همه‌ی فراخوانی‌های ابزار و افزودنِ نتیجه‌ها می‌رسد و دوباره به آغاز برمی‌گردد، حداکثر ۴۰ بار. هر چیزِ دیگر، مثل max_tokens یا refusal، به ایستادن و گفتنِ دلیل می‌رسد.

یک تغییرِ کوچک نسبت به بخشِ قبل: حالا run_tool فهرستِ tool_names را هم می‌گیرد، یعنی ابزارهایی که به این عامل داده شده. فراخوانیِ هر ابزارِ دیگری، حتی ابزاری که در TOOLS هست، به شکلِ خطا برمی‌گردد. این بعدها مهم می‌شود، وقتی یک عامل کاری را به زیرعاملی می‌سپارد که فقط اجازه‌ی خواندن دارد.

همه‌ی دلیل‌های ایستادن

stop_reason پاسخی است که می‌گوید مدل چرا از نوشتن ایستاد. حلقه باید با هر کدام درست رفتار کند:

  • "end_turn": کارِ مدل تمام شده و متنش همان جواب است. برش گردانید.
  • "tool_use": یک یا چند فراخوانیِ ابزار نوشته و منتظر است. همه را اجرا کنید و ادامه بدهید.
  • "max_tokens": به سقفِ max_tokens که برای یک پاسخ گذاشته‌اید رسیده. اگر وسطِ یک فراخوانیِ ابزار بوده، ممکن است ورودیِ آن نصفه مانده باشد، پس هرگز اجرایش نکنید. mini می‌ایستد و این را گزارش می‌دهد؛ یک هارنسِ زرنگ‌تر می‌تواند با سقفِ بزرگ‌تری دوباره امتحان کند.
  • "refusal": مدل به دلیل‌های ایمنی از ادامه دادن خودداری کرده. هیچ فراخوانیِ ابزاری از این پاسخ را اجرا نکنید؛ بایستید و آن را نشان بدهید.

mini با هر چیزی که end_turn یا tool_use نباشد یک جور رفتار می‌کند: می‌ایستد و می‌گوید دلیل چه بود. این قاعده حتی برای دلیلِ ایستادنی که بعد از نوشتنِ کدِ شما به API اضافه شود هم امن است.

سقفِ نوبت‌ها

MAX_TURNS = 40 یک نگهبان است، نه یک هدف. یک کارِ معمولی بینِ ۲ تا ۲۰ نوبت طول می‌کشد. ولی مدل ممکن است گیر کند: یک راه‌حلِ شکست‌خورده را بارها و بارها امتحان کند، یا دور خودش بچرخد و همان فایل‌ها را بخواند. بدونِ سقف، حلقه‌ی گیرکرده تا وقتی پولتان تمام شود می‌چرخد. چهل نوبت برای کارِ واقعی کافی است، و آن‌قدر کم است که یک اجرای گیرکرده چند سنت خرج بردارد، نه چند دلار. عامل‌های بزرگ‌تر زمان و پول را هم محدود می‌کنند؛ فکرش همین است.

فهرستِ پیام‌ها در سه دور. دورِ اول یک پیام می‌فرستد. دورِ دوم سه پیام: کار، فراخوانیِ ابزارِ مدل و نتیجه‌ی آن. دورِ سوم پنج پیام. هر درخواست همه‌ی چیزهای پیش از خودش را با خود می‌برد.


لیلا عجیب است. کلِ بخشِ «عامل» همین است؟ یک حلقه‌ی for و دو تا if؟

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

لیلا در ابزارها. و در اینکه چه چیزی مجاز است، چه چیزی در پیام‌ها بماند، چه چیزی در پرامپت سیستمی برود...

امید این همان بقیه‌ی این دوره است.


دیدنِ کاری که می‌کند

وقتی دارید می‌سازید، به کار می‌آید که هر فراخوانی را همان لحظه ببینید. یک خط درونِ حلقه، درست بعد از ساخته شدنِ calls، این کار را می‌کند:

        for b in calls:
            print(f"  [{turn + 1}] {b.name} {json.dumps(b.input)[:80]}")
Python

شماره‌ی نوبت، اسمِ ابزار و ابتدای ورودی‌اش را چاپ می‌کند، تا فکر کردنِ حلقه را تماشا کنید. (اگر import json بالای فایل نیست، اضافه‌اش کنید.)

امتحان کنید

run_agent را در mini خودتان بگذارید (درسِ بعد ابزارهای واقعیِ فایل به آن می‌دهد؛ فعلاً read_file را دارد)، کلیدتان را تنظیم کنید و آن را با کاری اجرا کنید که دو بار نگاه کردن لازم دارد، مثلِ «Which of notes.txt and todo.txt mentions Omid?». با خطِ چاپ، نوبت‌ها را بشمارید. با Haiku چنین اجرایی کسری از یک سنت خرج دارد. بدونِ کلید: تمرینِ بعد از این درس یک مدلِ ساختگی به شما می‌دهد که از روی یک متنِ از پیش نوشته پاسخ می‌دهد، و شما حلقه را دورِ آن می‌نویسید.

درست به کار بردن

  1. بر اساسِ stop_reason تصمیم بگیرید، نه بر اساسِ ظاهرِ محتوا. این گفته‌ی خودِ مدل است که چرا ایستاد، و حالت‌هایی را هم در بر می‌گیرد که با گشتن دنبالِ فراخوانیِ ابزار از چشمتان می‌افتد.
  2. هرگز فراخوانیِ ابزاری را از پاسخی که با tool_use تمام نشده اجرا نکنید. پاسخی که قطع یا رد شده ممکن است فراخوانیِ نیمه‌کاره داشته باشد.
  3. همیشه سقفِ نوبت بگذارید. آن را از روی طولانی‌ترین کاری که انتظار دارید انتخاب کنید، و کاری کنید که رسیدن به آن دیده شود، نه بی‌صدا.
  4. وقتی می‌سازید، فراخوانی‌ها را چاپ کنید. بیشترِ اشکال‌های حلقه همان لحظه‌ای که فراخوانی‌ها را می‌بینید آشکار می‌شوند.

واژه‌های این درس

  • run_agent - حلقه‌ی mini: بفرست، پاسخ را اضافه کن، ابزارها را اجرا کن، نتیجه‌ها را اضافه کن، تا وقتی کارِ مدل تمام شود یا به سقف برسد
  • end_turn - دلیلِ ایستادنی که یعنی کارِ مدل تمام شده و متنش جواب است
  • max_tokens - بیشترین توکنی که یک پاسخ می‌تواند داشته باشد؛ به‌عنوانِ دلیلِ ایستادن، یعنی پاسخ همان‌جا قطع شده
  • سقفِ نوبت (turn limit) - بیشترین تعداد دفعه‌هایی که حلقه می‌تواند بچرخد پیش از اینکه دست بکشد

ایده‌ی اصلی

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

دانش خود را بسنجید

ورود برای سنجش دانش خود از این درس و پیگیری پیشرفتتان.