Từ LLM đến HarnessPhần 4/11

Vòng while chạy được, cho tới lúc không

Một agent loop chạy được sau mười dòng. Bản production của cùng vòng lặp đó dài 101 dòng, và phần dôi ra đều là những chỗ nó từng vỡ. Đây là bốn chỗ đó.

Vòng lặp while tối giản với bốn nhánh vỡ được đánh dấu quanh nó
TL;DR — Agent loop chỉ là while cộng tool call. Bản tối giản chạy được sau mười dòng. Bản production của cùng vòng lặp đó dài gấp mười lần, và phần dôi ra là danh sách những chỗ nó từng vỡ.

Vòng lặp tối giản

Đây là toàn bộ một agent loop, và nó thật sự chạy:

agent-loop.ts — bản tối giản
let messages = [userMessage]
while (true) {
const reply = await callModel(messages)
messages.push(reply)
if (!reply.tool_calls?.length) break
for (const call of reply.tool_calls) {
const result = await runTool(call)
messages.push({ role: 'tool', tool_call_id: call.id, content: result })
}
}

Mười dòng. Bạn có một agent: nó gọi model, chạy tool nếu được yêu cầu, gửi kết quả về, lặp tới khi model thôi đề nghị. Về hình dạng, đây là toàn bộ ý tưởng.

Bây giờ nhìn bản thật.

Cùng vòng lặp đó, dài 101 dòng

Trong harness tôi đọc, hàm chạy một turn là 101 dòng. Cả package điều khiển vòng lặp là 2.490 dòng trên tám file.

Hàm turn() mở đầu bằng một chuỗi việc mà bản tối giản không có:

agent-loop/src/agent.ts — bản thật, rút gọn
phase.turn = turn
let turnEnds: TurnEndReason | null = null
let target: InboxTarget = 'next-turn'
while (true) {
signal.throwIfAborted()
const step = phase.step + 1
const decision = await this.preStep(target, { turn, step })
if (decision.kind === 'reject') return false
if (turnEnds && decision.messages.length === 0) break
this.session.append('step/start', { turn, step })
const stepEnd = await this.step(decision)
if (turnEnds === null || turnEnds.kind !== 'max-tokens') turnEnds = stepEnd
}

Cùng một while (true). Nhưng mỗi dòng thêm vào là một lớp lỗi đã từng xảy ra với ai đó.

Bốn chỗ vỡ

Chỗ vỡ thứ nhất: hết ngữ cảnh giữa chừng

Bản tối giản không có khái niệm ngân sách. messages cứ dài ra, và tới một lúc request vượt cửa sổ.

Chú ý dòng này trong bản thật:

if (turnEnds === null || turnEnds.kind !== 'max-tokens') turnEnds = stepEnd

Kết quả của turn được giữ dính (sticky). Một khi đã chạm trần token, các step sau có hoàn thành bình thường cũng không được hạ kết quả của turn xuống. Comment trong source nói thẳng lý do: nếu không dính, một turn từng chạm trần sẽ bị ghi lại như một turn thành công.

Đây là bài học của bài 2, viết thành một dòng if. Ngân sách ngữ cảnh không chỉ là chuyện ước lượng token. Nó còn là chuyện trạng thái của vòng lặp phải nhớ rằng nó từng thiếu chỗ.

Chỗ vỡ thứ hai: không biết lần trước đã làm gì

Bản tối giản giữ lịch sử trong một biến. Restart tiến trình là mất hết.

Bản thật ghi mọi thứ xuống một sổ: session.append('turn/start', …), session.append('step/start', …). Mỗi lần model nhìn thấy một sự kiện, sự kiện đó đã nằm trong log trước.

Và thứ tự có ý nghĩa: mở turn, rồi mới mở step, rồi mới gọi model. Nếu tiến trình chết giữa hai dòng, sổ vẫn cho biết nó chết ở đâu.

Chỗ vỡ thứ ba: model gọi tool sai mãi

Bản tối giản không có đường thoát nào ngoài việc model tự thôi. Model gọi sai tham số thì runTool ném lỗi, và vòng lặp chết.

Trong bản thật có hẳn một khối catch. Nó làm đúng một việc: ghi lại các kết quả tool đang chờ trước khi ném lỗi.

for (const event of toolRecovery.results()) {
this.session.append('tool/result', event.data, { … })
}

Comment giải thích vì sao. Nếu step thất bại mà kết quả tool chưa được ghi, log sẽ có một lời gọi tool không bao giờ có kết quả. Lịch sử mâu thuẫn. Lần resume sau dựng lại một hội thoại méo.

Model gọi sai là chuyện thường. Vòng lặp phải sống sót qua nó, không phải chết cùng nó.

Chỗ vỡ thứ tư: không dừng lại được

Bản tối giản không có cách nào dừng. Người dùng bấm cancel thì không có gì lắng nghe.

agent.ts có 15 điểm kiểm abort rải khắp vòng lặp, và tool-calls.ts có 20 chỗ chạm tới signal. Con số đó không phải trang trí. Mỗi await là một chỗ tiến trình có thể treo, và mỗi chỗ treo là một lần cancel không tới đích.

Và tài liệu thừa nhận một hạn chế thẳng thắn:

No built-in turn budget — tool calls or steering continue the current turn; a policy that bounds runaway turns must cancel from an existing lifecycle extension point such as agent/turn-stopping.— dsh, packages/core/agent-loop/README.md

Vòng lặp không tự biết dừng. Nếu bạn cần nó dừng, bạn phải gắn một policy vào điểm mở rộng. Đây không phải thiếu sót — đó là quyết định thiết kế, và nó đẩy trách nhiệm về phía người dựng hệ thống.

Bốn điểm vỡ của vòng lặp tối giảnTrục ngang là trạng thái và ngữ cảnh, trục dọc là kiểm soát thực thi và độ an toànHết ngữ cảnhCần quản lýngân sáchMất lịch sửCần sổ ghi sựkiện bền vữngTool sai lặp lạiCần cơ chế phụchồi và guardKhông thể dừngCần lan truyềnAbortSignal
Hình 1 — Bốn điểm vỡ của vòng lặp tối giản · tusgino
Xem dữ liệu diagram
type: quadrant
title: Bốn điểm vỡ của vòng lặp tối giản
desc: Trục ngang là trạng thái và ngữ cảnh, trục dọc là kiểm soát thực thi và độ an toàn
nodes:
  - id: q1
    label: Hết ngữ cảnh
    sub: Cần quản lý ngân sách
  - id: q2
    label: Mất lịch sử
    sub: Cần sổ ghi sự kiện bền vững
    focal: true
  - id: q3
    label: Tool sai lặp lại
    sub: Cần cơ chế phục hồi và guard
  - id: q4
    label: Không thể dừng
    sub: Cần lan truyền AbortSignal
    focal: true
edges: []

Hai tầng, không phải một

Bản tối giản có một vòng lặp. Bản thật có hai, lồng nhau.

Tầng ngoài là turn(): nó chạy các step cho tới khi turn kết thúc. Tầng trong là step(), và nó cũng có một while (true):

agent-loop/src/agent.ts — tầng trong
let firstAttempt = true
while (true) {
const { config, preparedCall } = await this.prepareRequest(turn, step, signal)
…
firstAttempt = false
…
}

Vòng trong này không lặp vì tool call. Nó lặp vì một lần gọi model có thể phải thử lại. Biến firstAttempt quyết định những việc chỉ được làm đúng một lần: ghi message người dùng vào log, và đánh dấu mở một request series.

Nếu bạn gộp hai tầng làm một, bạn không phân biệt được “model đề nghị thêm một bước” với “lần gọi này hỏng, thử lại”. Hai chuyện đó cần hai cách xử lý khác nhau, và trộn chúng là nguồn của những bug ghi trùng.

Ba mức thời gian

Đây là phần tôi thấy đáng nhớ nhất, và nó chỉ rõ ràng khi bạn đã nhìn cả hai tầng.

Vòng lặp vận hành trên ba đơn vị thời gian, và chúng không lồng nhau theo cách bạn tưởng:

Đơn vịĐịnh nghĩaQuan hệ
StepMột lần gọi model cộng các tool call mà phản hồi đó gây raNhỏ nhất
TurnMột lần xả hết input được nhận vàoChứa nhiều step
RoundMột vòng lặp policy bọc ngoài, ví dụ một goal roundChứa turn

Điểm dễ sai: một turn có thể chứa không step nào. Tài liệu ghi “zero or more steps”, và trong source có hẳn một nhánh cho trường hợp đó:

if (phase.step === 0 && decision.messages.length === 0) {
turnEnds = { kind: 'completed' }
return false
}

Một turn mở ra rồi đóng lại mà không gọi model lần nào. Nghe vô lý, cho tới khi bạn nhớ rằng có những lần đánh thức vòng lặp mà không có gì để làm. Bản tối giản sẽ gọi model một cách vô ích ở đúng chỗ đó.

Làm được ngay

  1. Tách trạng thái ra khỏi vòng lặp ngay từ đầuĐừng giữ lịch sử trong một biến cục bộ. Ghi nó xuống một sổ có thứ tự trước khi bạn cần resume, không phải sau khi bạn mất dữ liệu.
  2. Đặt tên cho ba đơn vị thời gianChốt step, turn, round trong tài liệu của bạn trước khi viết dòng đầu tiên. Sai ở đây thì mọi con số đo chi phí sau đó đều lệch.
  3. Viết đường thoát trước khi viết đường chạyCancel, trần token, và trần số vòng là ba thứ phải có trước khi vòng lặp chạy thật. Thêm sau là thêm vào một hệ thống đã từng treo.

Nguyên tắc

Vòng lặp dễ. Giữ nó sống qua một turn dài mới là phần khó. Và phần khó đó nằm hết ở những dòng không có trong bản tối giản.

Mỗi dòng dôi ra trong một agent loop là một lần vòng lặp từng vỡ. Bỏ một dòng là trả lại một lớp lỗi đã từng xảy ra.— tusgino
    • Agent loop là while cộng tool call, và bản tối giản mười dòng là đúng về hình dạng.
    • Đếm theo step khi tính chi phí: model bị tính tiền theo step, còn turn và round là đơn vị của policy.
    • Giữ kết quả turn dính: một turn từng chạm trần token không được ghi lại như turn thành công.
    • Ghi kết quả tool đang chờ trước khi ném lỗi, nếu không log sẽ có lời gọi tool không bao giờ có kết quả.
    • Vòng lặp không tự biết dừng; nếu cần nó dừng, hãy gắn policy vào một điểm mở rộng.

Câu hỏi thường gặp

Agent loop khác gì một vòng while thường?
Về hình dạng thì không khác. Nó gọi model, chạy tool nếu model yêu cầu, rồi lặp. Khác biệt nằm ở những gì phải đúng để vòng lặp sống sót qua một turn dài.
Vì sao không viết vòng lặp ngắn cho dễ đọc?
Vì mỗi dòng dôi ra là một lần vòng lặp từng vỡ. Bỏ một dòng là trả lại một lớp lỗi đã từng xảy ra, thường là lỗi im lặng.
turn, step và round khác nhau thế nào?
Step là một lần gọi model cộng các tool call mà phản hồi đó gây ra. Turn là một lần xả hết input được nhận vào, chứa nhiều step. Round là một vòng lặp policy bọc ngoài turn.
Harness có giới hạn số vòng lặp không?
Thường là không, và đây là hạn chế được tài liệu thừa nhận: không có ngân sách turn sẵn có. Tool call hoặc steering cứ tiếp tục turn hiện tại, nên policy chặn phải tự huỷ từ một điểm mở rộng.
Tú (tusgino)Tú (tusgino)

AI Software Engineer tại Đà Nẵng. Viết về kiến trúc hệ thống production và cái giá phải trả của từng lựa chọn.