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

Sổ ghi là nguồn, không phải bản sao

Mọi thứ model nhìn thấy phải nằm trong sổ trước. Ngữ cảnh gửi cho model không được lưu song song — nó được suy ra, và đó là khác biệt giữa nguồn với bản sao.

Một chồng sổ ghi với hai mũi tên toả ra hai thứ được suy ra từ nó
TL;DR — Mọi thứ model nhìn thấy phải nằm trong sổ trước. Ngữ cảnh không được lưu song song với sổ — nó được suy ra từ sổ. Đó là khác biệt giữa một nguồn và một bản sao.

Quyết định thứ tự

Có hai cách viết một agent, và chúng khác nhau ở thứ tự hai việc.

Cách thứ nhất: gọi model, nhận câu trả lời, rồi ghi lại những gì đã xảy ra. Log là bản ghi của cuộc hội thoại.

Cách thứ hai: ghi sự kiện vào log trước, rồi mới dựng ngữ cảnh từ log và gọi model. Log là nguồn.

Cách thứ nhất dễ hơn và sai theo một kiểu rất khó thấy. Nếu log là bản sao, thì có hai nơi giữ sự thật — mảng trong bộ nhớ và file trên đĩa. Mỗi lần bạn sửa một nơi, bạn phải nhớ sửa nơi kia. Quên một lần là hai bản lệch nhau, và cái lệch đó chỉ lộ ra khi bạn resume.

Harness tôi đọc chọn cách thứ hai, và nói thẳng:

A Session is an append-only log of typed SessionEvents — the single source of truth for an agent’s whole interaction history. The LLM message history is derived from the log, never stored separately; replay is re-derivation from the same events.— dsh, docs/subsystems/session.md

Ba vế. Append-only. Nguồn sự thật duy nhất. Và vế thứ ba mới là vế quyết định: replay là dựng lại, không phải đọc lại một bản đã lưu.

Ghi chưa chắc đã bền

Đây là chi tiết tôi thấy đáng nhớ nhất trong bài này, và nó là thứ hầu hết chúng ta viết sai.

append không hứa dữ liệu sống sót. Nguyên văn:

Persistence is best-effort: on resolution the batch is accepted, ordered, and visible to reads on this backend instance, but only a resolved flush promises it survives a crash — a backend may buffer or batch physical writes behind append.— dsh, docs/subsystems/persistence.md

Đọc kỹ: sau khi append trả về, event đọc lại được trong tiến trình này. Nhưng nó có thể vẫn nằm trong buffer. Chỉ flush mới là hàng rào bền vững.

Tài liệu gọi flush bằng một cái tên rất rõ: the durability barrier. Và nói tiếp điều kiện dùng: Callers that must survive a crash flush.

Nghĩa là ai cần sống sót qua crash thì phải tự gọi. Không có gì tự động làm hộ.

Đây là chỗ dễ viết sai nhất khi tự dựng harness. Bạn gọi append, thấy promise resolve, và tin rằng dữ liệu đã an toàn. Nó chưa an toàn. Nó chỉ đang ở một chỗ mà tiến trình hiện tại đọc được.

Crash là việc của reader

Một log bị crash giữa turn sẽ kết thúc bằng một turn/start mở mà không có turn/end.

Phản xạ đầu tiên của tôi là tầng lưu trữ sẽ cắt cái đuôi hỏng đó đi. Sai:

Persistence does not truncate or repair it — a single turn can be huge in a long-horizon task (many steps, large tool output), and those events were durably appended before the crash.— dsh, docs/subsystems/persistence.md

Lý do rất thực tế. Một turn có thể chứa hàng trăm event đã bền vững. Cắt nó đi là ném bỏ dữ liệu tốt chỉ vì thiếu một dòng đóng.

Nên tầng lưu trữ trả về log liên tục hợp lệ về mặt vật lý, và việc vá thuộc về reader. Khi resume, vòng lặp đọc log cũ, tính ra các event đóng còn thiếu, rồi ghi chúng vào qua đúng handle đó:

các event đóng được tính khi resume
interruptedTurnClosers // gồm ba loại:
// - lỗi tool còn thiếu
// - mọi step/end còn mở
// - một turn/end tổng hợp, reason: { kind: 'interrupted' }

Và có một ràng buộc đáng chú ý: việc vá chỉ được ghi khi bạn đang giữ quyền ghi. Một tiến trình khác mở cùng session ở chế độ ghi sẽ bị từ chối bằng SessionAlreadyOwnedError, chứ không để hai bên đua nhau vá.

Bảy cách một turn kết thúc

Turn không chỉ kết thúc theo một kiểu. Có bảy nhánh, và hai trong số đó không do vòng lặp nào phát ra:

NhánhNghĩa
completedXong bình thường
abortedCó yêu cầu huỷ
blockedBị policy chặn
errorThất bại, kèm dữ kiện lỗi có cấu trúc
max-tokensCó step chạm trần token
interruptedTurn mồ côi vì crash, được đóng lại sau
forkedTurn còn mở bị đóng ở ranh giới fork

Hai nhánh cuối là chỗ tôi dừng lại lâu. Không vòng lặp nào phát ra chúng. interrupted do resume ghi, hoặc do tầng truy vấn tổng hợp khi đọc nguội. Còn forked chỉ có trong fork seed.

Kiểu dữ liệu này còn merge-extensible — plugin thêm được nhánh của riêng nó bằng cách merge vào map. Nên mọi switch trên nó phải có default. Tài liệu nói rõ lý do: một biến thể do plugin thêm vào là giá trị lạ hợp lệ.

Một log, hai phép chiếu

Đây là hệ quả thú vị nhất của thiết kế log-là-nguồn, và tôi chưa thấy ở harness nào khác.

Từ cùng một sổ ghi (log), có hai phép chiếu độc lập:

  • Surface fold (Chiếu bề mặt): Dựng danh sách message gửi cho model. Khi một đoạn hội thoại bị thu gọn (compaction), bề mặt chỉ lấy bản tóm tắt mới và che các event cũ đi.
  • Append-origin (Chiếu gốc): Dựng lại toàn bộ dòng sự kiện cho con người đọc và kiểm toán. Bản ghi này vẫn giữ nguyên mọi event gốc kể cả khi model không còn nhìn thấy chúng.

Một nguồn dữ liệu duy nhất, sinh ra hai góc nhìn khác nhau cho hai đối tượng khác nhau.

Một turn đi qua năm thành phầnVòng lặp là bộ điều phối duy nhất ghi vào sổ, gọi model và lưu kết quả toolNgười dùngVòng lặpSổ ghi (Log)ModelTool
Hình 1 — Một turn đi qua năm thành phần · tusgino
Xem dữ liệu diagram
type: sequence
title: Một turn đi qua năm thành phần
desc: Vòng lặp là bộ điều phối duy nhất ghi vào sổ, gọi model và lưu kết quả tool
nodes:
  - id: user
    label: Người dùng
    kind: input
  - id: loop
    label: Vòng lặp
    focal: true
  - id: log
    label: Sổ ghi (Log)
    kind: store
    focal: true
  - id: model
    label: Model
    kind: external
  - id: tool
    label: Tool
edges:
  - [user, loop]
  - [loop, log]
  - [loop, model]
  - [model, loop]
  - [loop, tool]
  - [loop, log]

Cái giá

Không có bản sao để đọc nhanh.

Mọi câu hỏi về quá khứ đều là một lần dựng lại. Bạn không có mảng nào để in ra, không có bảng nào để query trực tiếp. Muốn biết step thứ ba của turn thứ hai có gì, bạn chạy phép fold trên log.

Và vì log là append-only, bạn cũng không sửa được quá khứ. Muốn “xoá” một thứ đã ghi, bạn ghi thêm một event thay thế nó — chính là cách compaction làm việc.

Đổi lại, bạn được ba thứ. Một nguồn sự thật duy nhất. Một hàm thuần từ log ra request. Và khả năng replay lại đúng những gì đã xảy ra.

Làm được ngay

  1. Chọn thứ tự trước khi viết dòng đầuGhi rồi mới gọi, hay gọi rồi mới ghi. Chọn cái thứ hai thì bạn đã có hai nguồn sự thật, và sẽ phải đồng bộ chúng mãi mãi.
  2. Tách append khỏi flush trong API của bạnĐừng để một hàm vừa nhận dữ liệu vừa hứa bền vững. Người gọi cần biết mình đang ở bên nào của hàng rào.
  3. Để reader vá, không phải writerCrash để lại log hở. Cắt nó là ném dữ liệu tốt. Hãy để lần resume sau tính ra phần còn thiếu và ghi bù.
  4. Đặt tên cho mọi cách kết thúcBảy nhánh nghe nhiều, nhưng mỗi nhánh là một tình huống bạn sẽ gặp. Gộp chúng thành error là mất khả năng phân biệt ca hiếm với ca thường.

Nguyên tắc

Chọn log làm nguồn là chọn một ràng buộc: không có chỗ nào khác được giữ sự thật. Ràng buộc đó khó chịu lúc đầu và trả tiền về sau.

Log không phải để debug. Log là ngữ cảnh. Nếu nó chỉ là bản ghi phụ, bạn đã có hai nguồn sự thật và chưa biết cái nào đúng.— tusgino
    • Ghi vào log trước rồi mới gọi model; nếu làm ngược lại, log chỉ là bản sao và hai bản sẽ lệch nhau.
    • append là best-effort; chỉ flush mới hứa dữ liệu sống sót qua crash. Ai cần bền vững thì phải tự flush.
    • Tầng lưu trữ cố ý không cắt và không vá log hỏng; việc vá thuộc về reader khi resume.
    • Bảy nhánh kết thúc turn, trong đó interrupted và forked không do vòng lặp nào phát ra.
    • Một log, hai phép chiếu: surface cho model, append-origin cho người đọc — và chúng cố ý khác nhau.

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

Vì sao không lưu lịch sử gửi cho model thành một mảng riêng?
Vì hai bản sao sẽ lệch nhau. Nếu log là nguồn và mảng kia chỉ là bản sao, mọi lần sửa một trong hai đều phải nhớ sửa cái còn lại.
append đã ghi xuống đĩa chưa?
Chưa chắc. append là best-effort: nó nhận batch và cho đọc lại trong tiến trình, nhưng chỉ flush mới hứa dữ liệu sống sót qua crash.
Crash giữa turn thì ai sửa log?
Reader. Tầng lưu trữ cố ý không cắt và không vá. Khi resume, vòng lặp đọc log cũ và tự ghi thêm các event đóng turn còn thiếu.
Log chỉ để debug khi có sự cố à?
Không. Log là ngữ cảnh. Ngữ cảnh gửi cho model được suy ra từ log, nên log không phải bản ghi phụ mà là nguồn của chính request.
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.