Pi từ mã nguồnpi v1.4.0

Chương 21Phần 6: Tổng hợp

So sánh và lộ trình học

Coding agent và Pi Durable đặt cạnh nhau, lộ trình đọc code và bài tập lần theo code.

  • 7 phút đọc
  • 8 đoạn code
  • 8 mục
Trong chương này8 mục
  1. Hai cách ghi lại một cuộc hội thoại
  2. Bảng đối chiếu
  3. Cây trong một file: lá là dòng cuối
  4. Hai cách dựng ngữ cảnh
  5. Hai mô hình thực thi
  6. Lộ trình đọc code
  7. Bài tập lần theo code
  8. Tóm tắt

Tới đây bạn đã thấy hai cách Pi ghi lại và chạy một cuộc hội thoại với mô hình: coding agent (session JSONL dạng cây, vòng lặp trong bộ nhớ) và Pi Durable (storage với dòng commit nguyên tử, mọi việc là task có checkpoint). Chương này đặt chúng cạnh nhau, vì so sánh là cách nhanh nhất để thấy mỗi lựa chọn thiết kế giải quyết vấn đề gì và phải trả giá gì. Cuối chương là lộ trình đọc code và các bài tập lần theo code.

Hai cách ghi lại một cuộc hội thoại#

Coding agent: một file, một cái cây#

Mỗi session là một file JSONL: dòng đầu là header, các dòng sau là entry. Mọi entry có id và parentId, nên cả file là một cái cây; “nhánh đang hoạt động” là đường đi từ gốc tới lá.

Pi Durable: một storage, nhiều loại bản ghi#

Một storage (bộ nhớ, SQLite, JSONL, IndexedDB hay Durable Object) chứa nhiều cuộc hội thoại, cùng với submission, task và document. Mọi thay đổi đi qua một dòng commit nguyên tử duy nhất.

Coding agent: mọi entry chung một đáy (id, parentId) và thuộc một trong mười một loại
pi/packages/coding-agent/src/core/session-manager.tsdòng 57–62, 182–194TypeScript
export interface SessionEntryBase {	type: string;	id: string;	parentId: string | null;	timestamp: string;}/** Session entry - has id/parentId for tree structure (returned by "read" methods in SessionManager) */export type SessionEntry =	| SessionMessageEntry	| ThinkingLevelChangeEntry	| ModelChangeEntry	| UsageEntry	| CompactionEntry	| BranchSummaryEntry	| CustomEntry	| CustomMessageEntry	| ContextEditEntry	| LabelEntry	| SessionInfoEntry;
Các loại entry được giải thích kỹ ở chương Session.
Pi Durable: cuộc hội thoại là một bản ghi riêng; entry tách phần cho mô hình (model) khỏi phần cho ứng dụng (data)
pi/packages/durable/src/types.tsdòng 396–409, 427–443TypeScript
/** Immutable identity, history ancestry, and task ownership of a transcript scope. */export type ConversationRecord = {	readonly id: ConversationId;	/** Fork source and inclusive parent entry through which history is inherited. */	readonly parent?: {		readonly conversationId: ConversationId;		readonly at: EntryId;	};	/** Creator edge used for attribution, subtree abort, and subtree idle waits. */	readonly owner?: {		readonly conversationId: ConversationId;		readonly taskId: TaskId;	};};/** Immutable transcript event with separate model-facing and application-facing payloads. */export type EntryRecord = {	readonly id: EntryId;	readonly conversationId: ConversationId;	/** Application-defined entry discriminator. */	readonly kind: string;	/** Messages contributed to model context; absent for display or bookkeeping entries. */	readonly model?: readonly Message[];	/** JSON payload consumed by views, extensions, or bookkeeping logic. */	readonly data?: JsonValue;	/** First entry in the active context selected by this entry. */	readonly head?: EntryId;	/** Context-only overrides of earlier visible entries. */	readonly edits?: readonly ContextEdit[];	/** Task that appended this entry, when it was produced by durable work. */	readonly byTaskId?: TaskId;};
Một fork chỉ là một cuộc hội thoại mới có parent trỏ vào cuộc hội thoại gốc tại một entry; lịch sử không bị sao chép. Xem chương Pi Durable.

Điểm khác dễ thấy nhất là mỗi entry của Durable mang hai phần tách bạch: model là các tin nhắn đóng góp vào ngữ cảnh mô hình, data là JSON tuỳ ý dành cho giao diện và extension. Coding agent giải quyết cùng nhu cầu bằng hai loại entry khác nhau: custom (dữ liệu của extension, không vào ngữ cảnh) và custom_message (vào ngữ cảnh, có details không gửi cho mô hình).

Một điểm giống nhau đáng chú ý: cả hai đều chỉ nối thêm, kể cả khi muốn “sửa” lịch sử. Coding agent có entry context_edit trỏ vào một entry cũ và thay hoặc bỏ phần nó đóng góp vào ngữ cảnh; Durable có trường edits với hành động omit hoặc replace. Bản gốc luôn còn đó; chỉ phép chiếu ra ngữ cảnh mô hình là thay đổi.

Bảng đối chiếu#

Khía cạnhCoding agentPi Durable
Đơn vị lưu trữMột file JSONL cho mỗi sessionMột storage chứa mọi hội thoại, task, submission, document
Cách ghiNối một dòng cho mỗi entry, không có giao dịchCommit nguyên tử gom entry, document, task, submission; được đánh số tuần tự
Định danhChuỗi 8 ký tự ngẫu nhiên lấy từ UUIDSố nguyên có “nhãn kiểu” (EntryId, TaskId…), tăng dần
Rẽ nhánhTrong cùng file, qua parentId; fork tạo file mới có parentSessionFork là hội thoại mới với parent: {conversationId, at}
Ngữ cảnh mô hìnhĐường từ gốc tới lá; compaction mới nhất cắt tại firstKeptEntryId; áp dụng context_editTừ “head marker” mới nhất (reset hoặc compaction) tới entry cuối; áp dụng edits
Trạng thái ngoài transcriptEntry custom của extension; file cài đặtDocument có kiểu, phạm vi session/hội thoại/task, chính sách lịch sử và fork
Công việc đang chạyTrong bộ nhớ của Agent; mất khi tiến trình chếtBản ghi task với checkpoint; chạy tiếp sau khi mở lại
Steer và follow-upHàng đợi trong bộ nhớ của AgentDocument pi.inbox và trạng thái của SubmissionRecord
Người xemMột giao diện (TUI, RPC hoặc SDK) trong cùng tiến trìnhBất kỳ số client nào qua viewState(), watch(), watchEvents()
CompactionChạy khi tới ngưỡng, chặn lượt tiếp theo; ghi compaction entryTask nền bắt đầu trước ngưỡng; đặt bản tóm tắt ở ranh giới lượt kế tiếp
Trạng thái phát hànhỔn định (1.x)Thử nghiệm, API có thể đổi giữa các bản

Cây trong một file: lá là dòng cuối#

Thiết kế cây của coding agent đáng học vì nó rất rẻ. Thêm một entry chỉ là nối một dòng; entry mới luôn là con của lá hiện tại, rồi trở thành lá:

Ghi entry: file chỉ được tạo khi đã có hội thoại thật, sau đó mỗi entry là một lần appendFileSync
pi/packages/coding-agent/src/core/session-manager.tsdòng 1172–1196TypeScript
_persist(entry: SessionEntry): void {	if (!this.persist || !this.sessionFile) return;	if (!this.flushed) {		if (!this._hasConversation()) return;		const fd = openSync(this.sessionFile, "wx");		try {			for (const e of this.fileEntries) {				writeFileSync(fd, `${JSON.stringify(e)}\n`);			}		} finally {			closeSync(fd);		}		this.flushed = true;	} else {		appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`);	}}private _appendEntry(entry: SessionEntry): void {	this.fileEntries.push(entry);	this.byId.set(entry.id, entry);	this.leafId = entry.id;	this._persist(entry);}

Rẽ nhánh còn rẻ hơn: branch() không ghi gì cả, nó chỉ dời con trỏ lá trong bộ nhớ. Entry tiếp theo sẽ có parentId trỏ vào điểm rẽ, và nhánh mới tự nhiên xuất hiện:

branch(): chỉ dời con trỏ lá, không sửa hay xoá entry nào
pi/packages/coding-agent/src/core/session-manager.tsdòng 1573–1584TypeScript
/** * Start a new branch from an earlier entry. * Moves the leaf pointer to the specified entry. The next appendXXX() call * will create a child of that entry, forming a new branch. Existing entries * are not modified or deleted. */branch(branchFromId: string): void {	if (!this.byId.has(branchFromId)) {		throw new Error(`Entry ${branchFromId} not found`);	}	this.leafId = branchFromId;}

Khi mở lại file, Pi không cần lưu con trỏ lá riêng: nó lấy entry cuối cùng trong file làm lá. Vì entry mới luôn được nối vào cuối, entry cuối chính là nơi bạn dừng lại.

Khi nạp file, lá là entry cuối cùng đọc được
pi/packages/coding-agent/src/core/session-manager.tsdòng 1103–1111TypeScript
private _buildIndex(): void {	this.byId.clear();	this.labelsById.clear();	this.labelTimestampsById.clear();	this.leafId = null;	for (const entry of this.fileEntries) {		if (entry.type === "session") continue;		this.byId.set(entry.id, entry);		this.leafId = entry.id;

Hai cách dựng ngữ cảnh#

Pi Durable không có cây trong một hội thoại; transcript của mỗi hội thoại là một dãy entry tăng dần. Thay vào đó, một số entry là head marker: chúng có trường head chỉ ra entry đầu tiên của ngữ cảnh đang hoạt động. pi.reset bắt đầu lại từ đầu, pi.compaction giữ bản tóm tắt và “dẫn đầu” entry đầu tiên được giữ lại. Ngữ cảnh luôn là head marker mới nhất cộng đoạn từ head của nó tới entry cuối:

deriveRange(): head marker mới nhất, các chỉnh sửa, thứ tự kết quả tool, rồi system message ở đầu
pi/packages/durable/src/harness/context.tsdòng 123–155TypeScript
/** * Derive the active transcript and model context of one conversation within captured bounds. * * H = newest visible head marker; the range runs from `H.head` (or transcript start) through the tail. Per target, * the newest edit in the range wins. Context entries are H followed by the range's non-head entries. */export async function deriveContext(	storage: Storage,	conversationId: ConversationId,	bounds: ContextBounds | undefined,	context: Context,): Promise<ContextView> {	if (bounds === undefined) return emptyView();	return deriveRange(bounds, await scanRange(storage, conversationId, rangeQuery(bounds), context)).view;}function emptyView(): ContextView {	return { head: undefined, entries: [], contributions: [], messages: [] };}/** Derive the context view from the entries scanned within `bounds`. */function deriveRange(bounds: ContextBounds, entries: readonly EntryRecord[]): ContextRange {	const edits = new Map<EntryId, ContextEdit>();	// Edits of every entry in the range count, including older head markers that `selectActive()` drops.	for (const entry of entries) for (const edit of entry.edits ?? []) edits.set(edit.target, edit);	const active = selectActive(bounds.head, entries);	const contributions = active.map((entry) => contribute(entry, edits.get(entry.id)));	const { settled, open } = settle([], contributions.flat());	const baseline = findBaseline(bounds.head, active);	const messages = leadWithSystem([...settled, ...orderToolResults(open)], baseline);	const view = { head: bounds.head, entries: active, contributions, messages };	return { bounds, entries, view, edited: new Set(edits.keys()), settled, open, baseline };}

Cả hai đều phải xử lý chung một tình huống khó: lời gọi tool không có kết quả (tiến trình chết giữa chừng, hoặc người dùng huỷ). Coding agent xử lý ở tầng pi-ai, ngay trước khi gửi request: nó bỏ qua tin nhắn assistant kết thúc bằng lỗi hoặc bị huỷ, và chèn một kết quả tool giả báo lỗi cho mọi lời gọi tool mồ côi, vì API của provider bắt buộc mỗi tool call phải có kết quả:

pi-ai: kết quả giả cho tool call mồ côi, bỏ qua lượt bị lỗi hoặc huỷ
pi/packages/ai/src/api/transform-messages.tsdòng 179–207, 216–224TypeScript
// Second pass: insert synthetic empty tool results for orphaned tool calls// This preserves thinking signatures and satisfies API requirementsconst result: Message[] = [];let pendingToolCalls: ToolCall[] = [];let existingToolResultIds = new Set<string>();// System messages are transparent to tool-call accounting: one that lands between a tool// call and its results is held back and emitted after the results (synthetic ones// included), so it never causes a duplicate result for a call that is answered later.const heldSystemMessages: Message[] = [];const closePendingToolCalls = () => {	if (pendingToolCalls.length > 0) {		for (const tc of pendingToolCalls) {			if (!existingToolResultIds.has(tc.id)) {				result.push({					role: "toolResult",					toolCallId: tc.id,					toolName: tc.name,					content: [{ type: "text", text: "No result provided" }],					isError: true,					timestamp: Date.now(),				} as ToolResultMessage);			}		}		pendingToolCalls = [];		existingToolResultIds = new Set();	}	result.push(...heldSystemMessages);	heldSystemMessages.length = 0;};		// Skip errored/aborted assistant messages entirely.		// These are incomplete turns that shouldn't be replayed:		// - May have partial content (reasoning without message, incomplete tool calls)		// - Replaying them can cause API errors (e.g., OpenAI "reasoning without following item")		// - The model should retry from the last valid state		const assistantMsg = msg as AssistantMessage;		if (assistantMsg.stopReason === "error" || assistantMsg.stopReason === "aborted") {			continue;		}

Durable chọn cách khác, sớm hơn: ý định gọi tool được commit trước khi tool chạy, nên sau sự cố harness biết chính xác tool nào đang chạy dở. Tool khai báo replay: "safe" thì được chạy lại; tool khác nhận kết quả lỗi interrupted kèm phần output đã kịp commit (chương Durable: task). Phần dựng ngữ cảnh vẫn có lưới an toàn tương tự pi-ai cho lịch sử bị cắt ngang:

Durable: các lượt bị huỷ, lỗi hoặc hoãn không vào ngữ cảnh; kết quả thiếu được thay bằng một câu giải thích
pi/packages/durable/src/harness/context.tsdòng 10–11TypeScript
const EXCLUDED_STOP_REASONS: ReadonlySet<AssistantMessage["stopReason"]> = new Set(["aborted", "error", "deferred"]);const MISSING_RESULT_TEXT = "Tool result unavailable: history ends before this call completed.";

Hai mô hình thực thi#

Coding agent#

  1. Người dùng gửi prompt; AgentSession chuẩn bị ngữ cảnh rồi gọi Agent.prompt().
  2. Vòng lặp trong pi-agent-core stream câu trả lời, chạy tool, phát sự kiện.
  3. Mỗi tin nhắn hoàn tất được nối vào file session.
  4. Tiến trình chết: phần đang stream và tool đang chạy mất; lần sau mở session, bạn bảo Pi làm tiếp.

Pi Durable#

  1. submit() commit một submission (có thể kèm requestId để không bao giờ gửi trùng).
  2. Task pi.generation gọi mô hình, commit từng phần câu trả lời vào document pi.live, rồi commit entry pi.assistant.
  3. Mỗi tool call là một task pi.tool do generation sở hữu; generation chờ chúng rồi trao lượt cho generation kế tiếp.
  4. Tiến trình chết: mở lại storage, resume() chạy tiếp từ checkpoint cuối cùng.

Lộ trình đọc code#

Nếu chỉ có thời gian đọc mười lăm file, hãy đọc theo thứ tự này. Mỗi file có chương tương ứng để đọc kèm.

#FileĐọc để hiểuChương
1ai/src/types.tsTin nhắn, nội dung, mô hình, sự kiện stream: “ngôn ngữ chung” của mọi tầngpi-ai
2ai/src/api/anthropic-messages.tsMột adapter provider hoàn chỉnh: dựng request, đọc stream, tính usagepi-ai
3ai/src/api/transform-messages.tsChuyển lịch sử giữa các provider, vá tool call mồ côipi-ai
4agent/src/agent-loop.tsVòng lặp agent: lượt, tool, steer, follow-upagent-core
5coding-agent/src/core/agent-session.tsMột prompt đi qua coding agent: extension, lưu session, retry, compactionAgentSession
6coding-agent/src/core/session-manager.tsĐịnh dạng session, cây, dựng ngữ cảnh từ nhánhSession
7coding-agent/src/core/system-prompt.ts và tools/edit.tsSystem prompt và một tool điển hìnhCông cụ
8coding-agent/src/core/compaction/compaction.tsKhi nào và cắt ở đâu để nén ngữ cảnhCompaction
9coding-agent/src/core/extensions/types.tsToàn bộ bề mặt API của extension và danh mục sự kiệnExtensions
10tui/src/tui.tsVẽ lại theo vi saipi-tui
11extensions/codemode/tool.tsTool codemode và cách nó gọi các tool khácCodemode
12chord/src/state.tsState quan sát được, nền của mọi “view” trong DurableChord
13durable/src/types.tsMọi bản ghi bền: hội thoại, entry, submission, task, documentPi Durable
14durable/src/session/transaction.tsMột commit chứa gì và vì sao nó nguyên tửDurable storage
15durable/src/harness/scheduler.ts và harness/tool.tsTask, checkpoint, phục hồi sau sự cố, replay của toolDurable tasks

Bài tập lần theo code#

Mỗi bài là một câu hỏi bạn trả lời bằng cách đọc code, kèm gợi ý nơi bắt đầu. Không cần chạy gì; nếu muốn chạy thử, hãy làm trong một thư mục tạm hoặc container, vì Pi chạy với quyền của bạn.

  1. Một lần bấm Enter. Từ lúc bạn gõ một câu trong editor tới khi token đầu tiên hiện ra, những hàm nào được gọi, theo thứ tự nào? Gợi ý: bắt đầu từ cách chế độ interactive gọi AgentSession.prompt() (chương Các chế độ chạy), rồi theo Agent.prompt() vào runLoop và streamAssistantResponse.
  2. Đọc một session có hai nhánh. Mở một file trong thư mục session của bạn (vị trí ở chương Session), tìm hai entry có cùng parentId. Dự đoán ngữ cảnh mà mô hình nhận được khi lá nằm ở nhánh thứ nhất, rồi đối chiếu với hàm dựng ngữ cảnh trong session-manager.ts.
  3. Chặn lệnh nguy hiểm. Ví dụ permission-gate.ts chặn rm -rf bằng sự kiện tool_call. Tìm nơi runner gọi các handler của sự kiện này và giải thích vì sao trả về { block: true } làm mô hình nhận một kết quả lỗi thay vì tool bị bỏ qua lặng lẽ. Gợi ý: runner.ts:1249 dừng ở handler đầu tiên chặn, AgentSession nối nó vào hook beforeToolCall của agent (agent-session.ts:653), và vòng lặp agent đổi lệnh chặn thành kết quả lỗi (agent-loop.ts:755).
  4. Vì sao prompt cache vẫn trúng sau khi thêm tool. Khi một extension bật thêm tool giữa hội thoại, request kế tiếp có làm thay đổi phần đầu của prompt không? Gợi ý: declareToolChanges trong agent-loop.ts và chương Pi 1.0.
  5. Compaction ở hai nơi. So sánh điều kiện kích hoạt compaction của coding agent với settings.compaction của Durable (reserveTokens, keepRecentTokens, backgroundTokens). Bên nào bắt người dùng chờ, bên nào không, và vì sao?
  6. Giết tiến trình giữa chừng. Trong Durable, nếu tiến trình chết lúc tool bash đang chạy npm test, sau khi mở lại thì mô hình thấy gì? Còn nếu tool được khai báo replay: "safe"? Gợi ý: ví dụ test/examples/13-recovery.ts của package durable và chương Durable: task. Muốn chạy ví dụ, cài dependency ở gốc repo bằng npm install --ignore-scripts rồi làm theo hướng dẫn trong README của package.
  7. Một fork không sao chép gì. Trong Durable, khi fork một hội thoại tại entry e, các truy vấn entry của hội thoại mới thấy những entry nào, và bằng cơ chế nào mà không cần sao chép? Gợi ý: src/session/forks.ts và chương Durable storage.

Tóm tắt#

  • Coding agent: một file JSONL dạng cây, chỉ nối thêm; lá là dòng cuối; rẽ nhánh chỉ là dời con trỏ; công việc đang dở sống trong bộ nhớ và lịch sử được “vá” khi gửi đi.
  • Pi Durable: một storage với dòng commit nguyên tử; entry tách model khỏi data; head marker chọn ngữ cảnh; mọi việc là task có checkpoint nên chạy tiếp được sau sự cố.
  • Cả hai cùng tôn trọng một nguyên tắc: không bao giờ xoá hay sửa lịch sử; mọi “chỉnh sửa” là một bản ghi mới thay đổi phép chiếu ra ngữ cảnh mô hình.

Mục lục

Giao diện