Agent Harness: mọi thứ bao quanh model, và cách cấu hình
Model có lẽ chỉ chiếm một phần ba của một agent. Phần còn lại là harness: file hướng dẫn, tool, permission, hook, file trạng thái, sandbox và trace. Bài này giải thích harness là gì, đi qua một cấu hình thật với Claude Code và Agent SDK, và pattern giữ cho agent làm việc xuyên nhiều session.

English version: Agent Harness: Everything Around the Model, and How to Configure It. Phần 2 của series. Phần 1: Agent Loop; phần 3: Context Engineering.
Hai năm trước, khi ai đó trong team nói một agent "không chạy", chúng tôi mở prompt ra xem. Bây giờ chúng tôi mở harness trước, và chín trên mười lần vấn đề nằm ở đó: một tool trả về quá nhiều, một quyền chưa bao giờ được cấp, một file progress không ai bảo agent đọc, một hook âm thầm chặn đúng cái lệnh nó cần. Model đã trở thành thành phần đáng tin nhất trong hệ thống. Giàn giáo chúng ta dựng quanh nó thì chưa.
"Harness" là từ mà ngành đã chốt cho cái giàn giáo đó. Anthropic dùng nó vào tháng 11/2025 để gọi cấu trúc chạy một model như agent xuyên nhiều session. OpenAI dùng nó vào tháng 2/2026 cho cả một kỷ luật làm việc, harness engineering, sau khi một team nhỏ ship khoảng một triệu dòng code trong năm tháng mà không tự tay viết dòng nào. Khẩu hiệu của họ, "Humans steer. Agents execute." (người lái, agent làm), là định nghĩa một dòng khá chuẩn về mục đích của harness.
Bài này là phiên bản thực hành: harness gồm những gì, mỗi phần trông ra sao dưới dạng file thật hoặc dòng code thật, và thứ tự tôi xây chúng.
Trong harness có gì
Lấy vòng lặp 40 dòng ở phần 1 và tự hỏi: còn gì phải tồn tại để vòng lặp đó chạy không người trông coi trên một codebase thật hay với một khách hàng thật mà vẫn an toàn? Bạn sẽ ra sáu lớp. Tôi vẽ chúng như các vòng tròn đồng tâm quanh model, vì lỗi lan ra đúng theo hướng đó: từ trong ra ngoài.
Vòng lặp: điều kiện dừng, budget, điều phối tool, retry. Đã nói ở phần 1.
Lớp hướng dẫn: các file nói cho model biết dự án này là gì, làm việc trong đó thế nào, và tìm gì ở đâu. CLAUDE.md, AGENTS.md, skill. System prompt thuộc lớp này, nhưng là phần nhỏ.
Lớp năng lực: các tool model gọi được, schema của chúng, và server cung cấp chúng (hàm local, MCP server, CLI). Agent có thể làm gì.
Lớp chính sách: permission, hook, sandbox. Agent được phép làm gì, ai duyệt phần còn lại, và chuyện gì xảy ra nếu nó cứ thử.
Lớp trạng thái: mọi thứ sống sót qua một session. Git, log tiến độ, danh sách feature, checkpoint. Không có nó, mỗi session bắt đầu từ số không và task dài vỡ vụn.
Lớp quan sát: trace, metric, kế toán chi phí, và eval ăn dữ liệu từ đó.
Framework cho bạn vòng lặp và một phần lớp năng lực. Phần còn lại là của bạn, và thời gian đi vào đó.
Một harness cụ thể: lấy Claude Code làm chuẩn
Tôi dùng Claude Code làm ví dụ vì harness của nó hiện ra trọn vẹn dưới dạng file trong repo, dễ lý luận, và vì chính các file đó được Agent SDK đọc khi bạn chuyển sang gọi bằng code. Nếu bạn dùng Codex, Cursor hay tool khác, tên file đổi còn ý tưởng thì không.
Hướng dẫn: bản đồ, không phải sách hướng dẫn
CLAUDE.md ở gốc repo được nạp vào mọi session. Cám dỗ là viết nó cho đầy đủ. Đừng. Team OpenAI nhận ra một file hướng dẫn lớn "chiếm chỗ context, lỗi thời, và khó kiểm chứng", và dừng lại ở khoảng một trăm dòng đóng vai mục lục. Chúng tôi cũng gặp y hệt: CLAUDE.md đầu tiên dài 600 dòng quy tắc, và agent tuân theo đâu đó 150 dòng đầu.
Một file tốt trông như thế này:
# Contract pipeline
Python 3.12, FastAPI, Postgres. Monorepo; mỗi service nằm dưới `services/<name>`.
## Commands
- Install: `uv sync`
- Test một service: `uv run pytest services/<name> -x -q`
- Lint + types: `uv run ruff check . && uv run mypy services`
- Không bao giờ chạy `alembic upgrade` lên bất cứ DB nào ngoài local.
## Cách làm việc
- Một feature một PR. Mở draft PR sớm với kế hoạch trong mô tả.
- Mọi bug fix đi kèm regression test trong cùng PR.
- Secret lấy từ `.env.local` (không commit). Không đọc file đó; dùng settings module.
## Tìm ở đâu
- Kiến trúc và luồng dữ liệu: `docs/architecture.md`
- Quy tắc trích xuất trường hợp đồng: `docs/extraction-rules.md`
- Test flaky đã biết và lý do: `docs/flaky-tests.md`
## Trước khi kết thúc
Chạy lint, types và test cho mọi service đã đụng vào. Tóm tắt đã đổi gì và chưa kiểm chứng gì.
Mọi thứ model cần ở mỗi lượt nằm ở đây. Mọi thứ nó thỉnh thoảng cần nằm trong docs/ và file này chỉ đường tới. Đó là progressive disclosure, và là kỹ thuật hiệu quả nhất ở lớp hướng dẫn mà tôi biết.
Skill: hướng dẫn nạp khi cần
Với kiến thức chỉ áp dụng cho một loại task, skill là đúng chỗ chứa. Skill là một thư mục có SKILL.md; mặc định chỉ description nằm trong context, phần thân được nạp khi task khớp. Đây là định dạng mở Agent Skills, nên cùng thư mục chạy được trên nhiều tool.
---
name: new-extractor
description: Thêm một extractor cho trường hợp đồng mới. Dùng khi được yêu cầu trích xuất thêm một trường, ngày hoặc loại bên ký kết từ tài liệu.
allowed-tools: Read, Grep, Edit, Bash(uv run pytest *)
---
1. Đọc `docs/extraction-rules.md` và extractor hiện có gần nhất với trường mới.
2. Tạo `services/extract/fields/<field>.py` theo mẫu `fields/effective_date.py`.
3. Thêm fixture dưới `tests/fixtures/<field>/` với ít nhất một PDF scan và một PDF native.
4. Chạy `uv run pytest services/extract -k <field>` tới khi xanh.
5. Bổ sung trường vào bảng trong `docs/extraction-rules.md`.
Đó là runbook một senior sẽ viết cho junior. Nó không tốn gì cho tới khi task khớp, và xoá được cả một lớp vấn đề "mỗi lần agent làm một kiểu".
Permission: cái gì chạy mà không cần hỏi
.claude/settings.json được commit và chia sẻ với cả team. Khối permissions quyết định agent được âm thầm làm gì, phải hỏi gì, và không bao giờ được làm gì. Mỗi rule gọi tên tool và một pattern.
{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"permissions": {
"allow": [
"Read",
"Grep",
"Glob",
"Bash(uv run pytest *)",
"Bash(uv run ruff *)",
"Bash(uv run mypy *)",
"Bash(git status)",
"Bash(git diff *)",
"Bash(git log *)"
],
"ask": [
"Bash(git push *)",
"Bash(docker *)",
"WebFetch"
],
"deny": [
"Read(./.env)",
"Read(./.env.*)",
"Read(./secrets/**)",
"Bash(rm -rf *)",
"Bash(alembic upgrade *)",
"Bash(git push --force *)"
]
}
}
Ba thói quen ở đây. Allow theo pattern, không theo tool: Bash(uv run pytest *) chứ không phải Bash. Mọi thứ đụng tới tiền, production hay mạng thì cho vào ask. Và viết rule deny cho những thứ khiến bạn mất ngủ, kể cả khi tin rằng model không bao giờ làm; deny là loại rule duy nhất có hiệu lực trước khi đồng nghiệp trust thư mục.
Hook: code chạy quanh mỗi tool call
Permission là tĩnh. Hook là code. Một hook chạy ở một sự kiện trong vòng đời (PreToolUse, PostToolUse, Stop, SessionStart và nhiều cái khác), nhận sự kiện dưới dạng JSON qua stdin, và có thể chặn, cho phép hoặc chú thích. Đây là chỗ đặt chính sách cần logic, và là chỗ gắn observability của riêng bạn.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [{ "type": "command", "command": "python3 .claude/hooks/guard_bash.py" }]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [{ "type": "command", "command": "python3 .claude/hooks/lint_changed.py" }]
}
]
}
}
Hook thứ hai đọc đường dẫn file vừa sửa từ JSON sự kiện (tool_input.file_path) và chạy ruff --fix lên đó, để format không bao giờ thành review comment. Hook thứ nhất là guard bên dưới. Exit code 2 chặn lệnh và gửi stderr về cho model làm lý do, đúng tinh thần "tool result biết dạy" ở phần 1, áp dụng trước khi tool chạy.
#!/usr/bin/env python3
# .claude/hooks/guard_bash.py — chặn lệnh shell phá hoại, nói rõ lý do
import json
import re
import sys
event = json.load(sys.stdin)
command = event.get("tool_input", {}).get("command", "")
RULES = [
(r"\bgit\s+push\b.*--force", "force-push is not allowed; open a PR instead"),
(r"\bgit\s+reset\s+--hard", "hard reset discards work; use `git stash` or a new branch"),
(r"\brm\s+-rf\s+(/|~|\.)", "refusing to delete the tree root; delete specific paths"),
(r"\bcurl\b.*\|\s*(ba)?sh\b", "piping remote scripts into a shell is not allowed"),
(r"DROP\s+(TABLE|DATABASE)", "schema changes go through a migration file"),
]
for pattern, reason in RULES:
if re.search(pattern, command, re.IGNORECASE):
print(f"Blocked: {reason}", file=sys.stderr)
sys.exit(2)
sys.exit(0)
Tôi giữ thêm một hook PreToolUse thứ hai ghi mọi tool call vào trace JSONL kèm session id, nên lớp quan sát coi như có sẵn. Ở sự kiện Stop, hook thứ ba chạy bộ test và, nếu fail, trả về thông điệp yêu cầu agent làm tiếp. Cái cuối cùng đó thay đổi tỷ lệ hoàn thành của chúng tôi nhiều hơn mọi lần sửa prompt cộng lại.
Năng lực: MCP server
Tool không phải hàm trong process của bạn thì đi vào qua MCP. .mcp.json trong repo khai báo chúng cho cả team:
{
"mcpServers": {
"postgres-readonly": {
"command": "uvx",
"args": ["mcp-server-postgres", "--read-only"],
"env": { "DATABASE_URL": "${PGRO_URL}" }
},
"browser": {
"command": "npx",
"args": ["-y", "@playwright/mcp@latest", "--headless"]
}
}
}
Để ý --read-only. Năng lực và chính sách là hai lớp khác nhau, nhưng chính sách rẻ nhất là một năng lực không thể làm điều nguy hiểm. Tool database read-only không cần rule deny nào.
Cùng harness đó, bằng code
Khi agent chạy như một phần của service thay vì trong terminal, bạn dựng harness bằng code. Claude Agent SDK lộ ra đúng các lớp đó dưới dạng option: hướng dẫn qua system_prompt và setting_sources (nó sẽ đọc CLAUDE.md và file settings của bạn), năng lực qua tool và mcp_servers, chính sách qua permission_mode, allowed_tools và hooks.
# harness.py — một reviewer agent với một tool tự định nghĩa và một hook guard
import asyncio
from claude_agent_sdk import (
ClaudeAgentOptions, HookMatcher, create_sdk_mcp_server, query, tool,
)
@tool("ticket_lookup", "Fetch a support ticket by id from the internal tracker", {"ticket_id": str})
async def ticket_lookup(args):
# Thay bằng client thật của bạn. Trả về text; model sẽ đọc nó.
record = {"id": args["ticket_id"], "status": "open", "summary": "Extraction misses signing date on scanned PDFs"}
return {"content": [{"type": "text", "text": str(record)}]}
async def guard(input_data, tool_use_id, context):
"""PreToolUse hook: từ chối mọi ghi file ngoài services/ hoặc tests/."""
if input_data.get("tool_name") in {"Edit", "Write"}:
path = input_data.get("tool_input", {}).get("file_path", "")
if not (path.startswith("services/") or path.startswith("tests/")):
return {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": f"Edits are limited to services/ and tests/, got {path}",
}
}
return {}
tracker = create_sdk_mcp_server(name="tracker", version="1.0.0", tools=[ticket_lookup])
options = ClaudeAgentOptions(
cwd="/srv/contract-pipeline",
setting_sources=["project"], # nạp CLAUDE.md, .claude/settings.json, skills
system_prompt="You are reviewing a bug report and proposing a fix with tests.",
mcp_servers={"tracker": tracker},
allowed_tools=["Read", "Grep", "Glob", "Edit", "Bash", "mcp__tracker__ticket_lookup"],
permission_mode="acceptEdits",
hooks={"PreToolUse": [HookMatcher(matcher="Edit|Write", hooks=[guard])]},
max_turns=40,
)
async def main():
async for message in query(prompt="Investigate ticket T-4821 and open a draft fix.", options=options):
print(message)
asyncio.run(main())
Mọi thứ trong bản terminal đều có tương đương ở đây, và các file trong repo vẫn được tôn trọng nhờ setting_sources. Điểm chính không phải SDK. Điểm chính là bạn gọi tên được từng lớp và chỉ vào đúng dòng cài đặt nó.
Harness cho việc sống lâu hơn một session
Context window có kết thúc. Process có crash. Một task tốn cả ngày thời gian agent sẽ trải qua nhiều session, và mỗi session mới bắt đầu mà không nhớ gì về session trước. Bài về long-running agent của Anthropic mô tả hai kiểu hỏng đúng như chúng tôi từng thấy: agent cố làm quá nhiều trong một lần, cạn context giữa chừng một feature và để lại đống dở dang, hoặc nó thấy tiến độ sẵn có rồi tuyên bố cả dự án đã xong quá sớm.
Cách sửa là một lớp trạng thái mà agent được yêu cầu duy trì, cộng với việc tách session đầu tiên khỏi mọi session sau.
Initializer chạy một lần. Nó bung task thành danh sách feature (JSON, vì model làm hỏng JSON ít hơn Markdown khi chỉnh sửa), viết init.sh để khởi động môi trường, tạo log tiến độ và commit đầu tiên. Worker chạy ở mọi session sau và làm đúng một feature: đọc log tiến độ và git log, chọn feature ưu tiên cao nhất có passes là false, chạy init.sh, kiểm tra end-to-end nhanh để bắt thứ session trước làm hỏng, cài đặt, test, lật passes, commit, ghi vào log. Worker được đổi passes; không được sửa hay xoá feature. Harness điều khiển việc này khá nhỏ:
# sessions.py — chạy một task dài như chuỗi các session có giới hạn
import asyncio, json, subprocess
from pathlib import Path
from claude_agent_sdk import ClaudeAgentOptions, query
ROOT = Path("/srv/project")
FEATURES = ROOT / "features.json"
INIT_PROMPT = """You are starting a new project. Read SPEC.md. Produce:
1. features.json: a list of {id, category, description, test_steps, passes:false}, exhaustive, small items.
2. init.sh: starts the dev environment and dev server.
3. claude-progress.txt: an empty log with a header describing the project.
Commit everything. Do not implement features yet."""
WORK_PROMPT = """Continue the project. First: `git log --oneline -20`, read claude-progress.txt and features.json.
Run ./init.sh and do a quick end-to-end check of the app; fix anything the last session left broken.
Then pick ONE feature with passes=false (highest priority first), implement it, test it as a user would,
set passes=true only if it really passes, commit with a descriptive message, and append what you did
and what is left to claude-progress.txt. Never remove or edit feature entries. Stop after one feature."""
def remaining() -> int:
return sum(not f["passes"] for f in json.loads(FEATURES.read_text()))
async def session(prompt: str) -> None:
options = ClaudeAgentOptions(cwd=str(ROOT), permission_mode="acceptEdits",
setting_sources=["project"], max_turns=60)
async for msg in query(prompt=prompt, options=options):
pass # stream vào trace của bạn ở đây
async def main(max_sessions: int = 50):
if not FEATURES.exists():
await session(INIT_PROMPT)
for i in range(max_sessions):
left = remaining()
print(f"session {i}: {left} features remaining")
if left == 0:
break
await session(WORK_PROMPT)
subprocess.run(["git", "-C", str(ROOT), "status", "--short"])
asyncio.run(main())
Hai thứ tạo ra khác biệt khi chúng tôi áp dụng pattern này. Bắt worker kiểm chứng việc của session trước trước khi bắt đầu việc mới, vì session trước thường hết context vào đúng lúc tệ nhất. Và ép "test như người dùng thật": agent rất sẵn lòng đánh dấu feature xong sau một unit test hay một lệnh curl; cho nó tool trình duyệt và một quy tắc bắt buộc dùng đáng giá hơn mọi lần nâng model trong năm đó.
Harness engineering, như một kỷ luật
Bài của OpenAI đáng đọc trọn, nhưng những thực hành chuyển giao được sang team nhỏ hơn nhiều của chúng tôi là thế này. Repo là nguồn sự thật duy nhất: thứ gì agent không với tới được từ bên trong repo thì "coi như không tồn tại", nên design doc, kế hoạch và ghi chú chất lượng nằm trong docs/, không nằm trong chat. Linter tự viết mang kèm hướng dẫn sửa ngay trong thông báo lỗi để agent tự sửa vi phạm. Ràng buộc kiến trúc (hướng phụ thuộc được phép giữa các lớp) được cưỡng chế bằng máy thay vì mô tả bằng văn xuôi. Và sự trôi dạt được dọn bởi các agent "garbage collection" chạy nền, so code với một danh sách ngắn nguyên tắc vàng rồi mở PR nhỏ tự merge, thay cho buổi dọn dẹp thủ công hằng tuần không scale nổi.
Không có gì trong đó là lạ. Vẫn là kỷ luật kỹ thuật ta đã áp cho con người, viết đủ chính xác để model làm theo, và cưỡng chế bằng tool thay vì review comment.
Thứ tự tôi xây
Nếu bạn đang đi từ vòng lặp, đây là thứ tự đã có lãi với chúng tôi, và cũng gần như là thứ tự mức độ đắt đỏ khi mỗi lớp hỏng.
Một, chính sách và sandbox: rule deny, mặc định read-only, một container hoặc worktree agent không thoát ra được. Trước mọi thứ khác, vì sự cố đầu tiên luôn là một lệnh phá hoại. Hai, trace, vì đằng nào bạn cũng phải giải thích sự cố đầu tiên. Ba, file hướng dẫn, ngắn, có chỉ đường. Bốn, hook cho hai ba bất biến bạn quan tâm nhất (test phải pass khi stop, không sửa file ngoài thư mục service). Năm, lớp trạng thái, chỉ khi task bắt đầu sống lâu hơn một session. Sáu, eval, sẽ có bài riêng.
Thứ tôi sẽ mua thay vì tự làm: vòng lặp, giao diện terminal, transport MCP, và sandbox. Thứ tôi luôn tự làm: file hướng dẫn, hook, thông điệp lỗi của tool và định dạng trace, vì chúng mã hoá những gì đặc thù của codebase và rủi ro của bạn, và không ai viết hộ được.
Tài liệu tham khảo
- Anthropic, Effective harnesses for long-running agents (11/2025)
- OpenAI, Harness engineering: leveraging Codex in an agent-first world (02/2026)
- Tài liệu Claude Code: Hooks, Settings, Skills, Agent SDK (Python)
- AGENTS.md, định dạng file hướng dẫn mở dùng bởi Codex, Cursor, Gemini CLI và nhiều tool khác