---
title: "Sổ ghi là nguồn, không phải bản sao"
description: "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."
pubDate: 2026-09-25
author: tusgino
tags: [ai-engineering, architecture, agents, infrastructure]
series:
  id: tu-llm-den-harness
  title: "Từ LLM đến Harness"
  order: 7
cover: /images/covers/so-ghi-la-nguon-khong-phai-ban-sao.svg
coverAlt: "Một chồng sổ ghi với hai mũi tên toả ra hai thứ được suy ra từ nó"
faq:
  - q: "Vì sao không lưu lịch sử gửi cho model thành một mảng riêng?"
    a: "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."
  - q: "append đã ghi xuống đĩa chưa?"
    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."
  - q: "Crash giữa turn thì ai sửa log?"
    a: "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."
  - q: "Log chỉ để debug khi có sự cố à?"
    a: "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."
---

<TLDR>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.</TLDR>

## 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:

<Quote cite="dsh, docs/subsystems/session.md">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.</Quote>

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:

<Quote cite="dsh, docs/subsystems/persistence.md">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.</Quote>

Đọ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:

<Quote cite="dsh, docs/subsystems/persistence.md">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.</Quote>

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 đó:

```ts title="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ánh | Nghĩa |
|---|---|
| `completed` | Xong bình thường |
| `aborted` | Có yêu cầu huỷ |
| `blocked` | Bị policy chặn |
| `error` | Thất bại, kèm dữ kiện lỗi có cấu trúc |
| `max-tokens` | Có step chạm trần token |
| `interrupted` | Turn mồ côi vì crash, được đóng lại sau |
| `forked` | Turn 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.

```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.

<Callout type="warn" title="Log không phải để debug. Log là ngữ cảnh.">
Đây là chỗ tôi hay bị hiểu sai nhất khi đọc về event sourcing. Log không phải bản ghi phụ để soi khi có sự cố. Log là thứ request được dựng ra từ đó. Nếu log thiếu một event, model không thấy nó — và không có đường nào khác để nó tới được.
</Callout>

## Làm được ngay

<Steps>
<Step title="Chọn thứ tự trước khi viết dòng đầu">Ghi 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.</Step>
<Step title="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.</Step>
<Step title="Để reader vá, không phải writer">Crash để 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ù.</Step>
<Step title="Đặt tên cho mọi cách kết thúc">Bả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.</Step>
</Steps>

## 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.

<Quote cite="tusgino">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.</Quote>

<KeyTakeaways>
- 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.
</KeyTakeaways>
