---
title: "Model đề nghị, code của bạn thực thi"
description: "Model chỉ thấy phần khai báo của tool, không thấy phần thực thi. Một tool call là yêu cầu có cấu trúc mà code của bạn kiểm tra, kiểm quyền, rồi quyết định chạy."
pubDate: 2026-09-21
author: tusgino
tags: [ai-engineering, architecture, security, llm]
series:
  id: tu-llm-den-harness
  title: "Từ LLM đến Harness"
  order: 3
cover: /images/covers/model-de-nghi-code-thuc-thi.svg
coverAlt: "Ranh giới giữa phần khai báo tool gửi cho model và phần thực thi nằm lại phía hệ thống"
faq:
  - q: "Model có tự chạy được tool không?"
    a: "Không. Model chỉ phát ra một yêu cầu có cấu trúc gồm tên tool và tham số. Một chương trình khác — của bạn — đọc yêu cầu đó, kiểm tra, rồi quyết định có chạy hay không."
  - q: "Vì sao model không thấy code thực thi của tool?"
    a: "Vì hàm thực thi không phải dữ liệu. Nó không tuần tự hoá được thành JSON để gửi đi, nên ở tầng kiểu dữ liệu nó đơn giản là không tồn tại trong payload."
  - q: "Nếu tham số model gửi lên sai thì sao?"
    a: "Lỗi đó được trả về như một kết quả tool bình thường, không phải ngoại lệ làm sập chương trình. Model đọc được thông báo lỗi và có thể sửa ở lượt sau."
  - q: "Bao nhiêu trường của một tool là phần hệ thống?"
    a: "Trong harness tôi đọc: mười hai trường tổng cộng, model thấy bốn. Tám trường còn lại gồm hàm thực thi, hàm kết xuất và metadata điều phối, không bao giờ được gửi đi."
---

<TLDR>Model không chạy tool. Nó phát ra một yêu cầu có cấu trúc, và code của bạn quyết định có thực thi hay không. Ranh giới đó là ranh giới tin cậy, và nó nằm ở tầng kiểu dữ liệu.</TLDR>

## Lời gọi tool trông như model đang hành động

Đây là thứ bạn thấy trong log khi model "gọi tool":

```json title="model trả về, không phải model chạy"
{
  "role": "assistant",
  "tool_calls": [
    {
      "id": "call_a7f3",
      "type": "function",
      "function": {
        "name": "write_file",
        "arguments": "{\"path\":\"/etc/hosts\",\"content\":\"127.0.0.1 api.internal\"}"
      }
    }
  ]
}
```

Trông như model vừa ghi vào `/etc/hosts`. Nó không viết gì cả. Nó vừa **đề nghị** một thao tác ghi, dưới dạng JSON.

Không có dòng nào trong payload đó chạy được. Không có hàm, không có đường dẫn thực thi, không có gì để thực thi. Đó là dữ liệu mô tả một hành động, và mọi thứ xảy ra sau đó là quyết định của code bạn.

Đây là ranh giới tin cậy quan trọng nhất trong một harness. Nó thường bị hiểu sai theo hướng nguy hiểm: người ta tưởng model có quyền, nên đi tìm cách chặn model. Thực tế quyền nằm ở phía chạy lời gọi.

## Mười hai trường, model thấy bốn

Trong harness tôi đọc, một tool được đăng ký là một object có **12 trường**, chia làm hai nhóm rõ rệt. Nhóm thứ nhất hướng ra model:

| Trường | Vai trò |
|---|---|
| `name` | Tên tool, model dùng để gọi |
| `description` | Mô tả để model biết khi nào nên gọi |
| `parameters` | JSON Schema của tham số |
| `deferLoading` | Cờ nạp chậm định nghĩa tool vào ngữ cảnh |

Nhóm thứ hai ở lại phía hệ thống: **8 trường**, gồm `output`, `execute`, `projectContent`, `finalizeContent`, `timeoutMs`, `isConcurrencySafe`, `presentCall`, `presentResult`.

Tám trường đó gồm:

- hàm thực thi,
- hàm kết xuất nội dung,
- ngân sách thời gian,
- bộ phân loại chạy song song,
- hai hàm trình bày giao diện.

Không trường nào là dữ liệu. Không trường nào tuần tự hoá được thành JSON để gửi đi.

Và tài liệu của chính harness đó nói thẳng điều này:

<Quote cite="dsh, docs/subsystems/tools.md">A ToolSchema (the model-facing fields) plus a mandatory canonical output declaration, the execute function, host-only scheduler metadata, an optional final-content callback, and optional UI presenters. The registry's schemas() builds the model-facing ToolSchema[] by an explicit allowlist — output/execute/projectContent/finalizeContent/timeoutMs/isConcurrencySafe/presentCall/presentResult must never leak into a model request.</Quote>

"Host-only" là từ khoá. Metadata đó thuộc về máy chủ, và ranh giới không phải một quy ước — nó là một hàm chỉ định rõ từng trường.

```ts title="cách harness dựng phần gửi cho model — rút gọn"
private schemaOf(definition: ToolDefinition, detachParameters: boolean): ToolSchema {
  const { name, description, parameters, deferLoading } = definition
  const detached = detachParameters ? snapshotJsonValue(parameters) : parameters
  if (detached === undefined) {
    throw new Error(`tool "${name}" parameters must be lossless JSON before schema projection`)
  }
  return {
    name,
    description,
    parameters: detached,
    ...deferLoading === true ? { deferLoading } : {},
  }
}
```

Đọc hàm này chậm một chút, vì toàn bộ luận điểm của bài nằm ở đây. Nó **destructure bốn trường** rồi **trả về bốn trường**. Không có spread `...definition` ở đâu cả.

Nghĩa là `execute` không bị lọc bỏ. Nó chưa bao giờ được đọc tới. Muốn gửi hàm thực thi lên wire thì phải sửa chính hàm này. Không ai sửa nhầm một hàm có tên `schemaOf` mà không biết mình đang làm gì.

```diagram
type: layers
title: Ranh giới tin cậy cắt ngang một tool
desc: Bốn trường hướng ra model nằm trên đường ranh giới; tám trường còn lại không bao giờ rời khỏi tiến trình
nodes:
  - id: wire
    label: Bốn trường gửi cho model
    kind: external
  - id: boundary
    label: Ranh giới tin cậy
    kind: security
    focal: true
  - id: host
    label: Tám trường ở lại máy chủ
    kind: store
  - id: exec
    label: execute chạy trong tiến trình của bạn
    focal: true
edges:
  - [wire, boundary]
  - [boundary, host]
  - [host, exec]
```

## Ba thứ chỉ code của bạn biết

### Thời hạn chạy

`timeoutMs` khai báo ngân sách thời gian cho một lời gọi, và tài liệu ghi rõ nó **không bao giờ được gửi cho model**. Model không biết tool của bạn có hạn 5 giây hay 5 phút.

Hệ quả: model không thể tự điều chỉnh chiến lược theo ngân sách thời gian. Nếu bạn cần model biết, bạn phải nói trong `description`. Nhưng khi đó model biết *một con số bạn kể*, không phải con số hệ thống đang thực sự áp.

### Quyền chạy song song

`isConcurrencySafe` là một hàm phân loại đồng bộ, chỉ trả `true` mới được xếp vào nhóm chạy song song. Mặc định là không — thiếu trường, hàm ném lỗi, hay trả về bất cứ thứ gì khác `true` đều bị coi là loại trừ.

Đây là mặc định đúng. Nó bảo vệ bạn khỏi một lớp lỗi khó tái hiện: hai lời gọi tool cùng sửa một tài nguyên. Model không thấy cờ này, nên model không thể tự bật nó lên.

### Cách kết quả được kể lại

`render` và `presentCall` biến giá trị thô thành nội dung cho model và thành thẻ cho giao diện. Cùng một kết quả, hai cách kể khác nhau — và cả hai đều do bạn viết.

Điểm quan trọng: model đọc bản kể lại, không đọc giá trị gốc. Bạn kiểm soát được model thấy gì về những gì vừa xảy ra.

## Từ đề nghị đến thực thi

Khi model muốn gọi tool, toàn bộ chu trình đi qua một đường biên tin cậy rõ ràng:

```diagram
type: sequence
title: Ranh giới tin cậy giữa đề nghị và thực thi
desc: Model chỉ phát ra JSON đề nghị; code của bạn kiểm tra quyền, thực thi và trả kết quả
nodes:
  - id: model
    label: Model
    kind: external
  - id: host
    label: Code của bạn
    focal: true
  - id: tool
    label: Hàm thực thi
    kind: security
edges:
  - [model, host]
  - [host, tool]
  - [tool, host]
  - [host, model]
```

Chu trình này có bốn nhịp dứt khoát:

1. **Model phát ra đề nghị:** Một chuỗi JSON thuần túy chứa tên tool và tham số.
2. **Code của bạn kiểm soát:** Kiểm tra quyền, áp đặt thời hạn (timeout) — model hoàn toàn không thể tự ý vượt rào.
3. **Thực thi cô lập:** Hàm thực thi chỉ chạy trong tiến trình của bạn khi được cấp phép.
4. **Đóng gói trả về:** Kết quả được định dạng thành message và đưa vào lịch sử hội thoại cho lượt kế tiếp.

Đó là thiết kế đúng. Nếu kiểm quyền nằm trong thân tool, mỗi tool phải tự nhớ kiểm, và một tool quên là một lỗ hổng. Đặt ở ngoài thì mọi tool đều đi qua cùng một cổng.

Ghi chú thứ hai: chỉ chặng `tools/execute` mới được thay `signal` huỷ. Các chặng khác không chạm được vào nó. Đây là chi tiết nhỏ nhưng là dạng ràng buộc mà bạn chỉ làm đúng được nếu thiết kế ranh giới trước.

## Khi model gửi tham số sai

Đây là chỗ tôi thấy hay nhất, và cũng là chỗ dễ làm sai nhất.

Model đoán sai tên tham số là chuyện thường. Câu hỏi kiến trúc là: lỗi đó đi đâu?

Câu trả lời đúng là nó **trở thành một kết quả tool**. Không phải ngoại lệ làm sập vòng lặp, không phải log câm. Harness tôi đọc ném hai loại lỗi có tên riêng. `ToolArgsError` báo tham số không khớp schema, còn `ToolOutputError` báo giá trị trả về sai kiểu.

Tài liệu ghi rõ cả hai đi qua **đường tool-error bình thường**.

Nghĩa là model đọc được thông báo lỗi và tự sửa ở lượt sau. Vòng lặp không chết vì một tham số viết sai.

<Callout type="warn" title="Nếu model chạy được thứ nó đề nghị, bạn không có hệ thống agent">
Bạn có một shell gắn giao diện chat. Khác biệt không nằm ở chỗ bạn viết prompt cẩn thận hơn. Nó nằm ở chỗ tồn tại một chương trình giữa đề nghị và hành động, và chương trình đó do bạn viết.
</Callout>

## Làm được ngay

<Steps>
<Step title="Tách phần khai báo khỏi phần thực thi bằng kiểu dữ liệu">Đừng dựa vào quy ước. Viết một hàm dựng phần gửi đi theo allowlist, destructure đúng những trường được phép, và không spread object gốc.</Step>
<Step title="Đặt cổng kiểm quyền ngoài tool">Allow, deny, ask phải là một chặng mà mọi tool đều đi qua. Kiểm quyền trong thân tool là kiểm quyền sẽ bị quên.</Step>
<Step title="Trả lỗi tham số về như một kết quả">Một tham số sai là dữ liệu cho lượt sau, không phải ngoại lệ. Log nó, và gửi nó về cho model đọc.</Step>
</Steps>

## Nguyên tắc

Đọc `schemaOf` của một harness là cách nhanh nhất để biết nó có ranh giới thật hay chỉ có quy ước. Ranh giới thật thì nhìn thấy được trong vài dòng code.

<Quote cite="tusgino">Model đề nghị. Code của bạn thực thi. Nếu hai việc đó nằm cùng một chỗ, bạn không có hệ thống agent — bạn có một shell có giao diện chat.</Quote>

<KeyTakeaways>
- Một lời gọi tool là JSON, không phải hành động. Không có dòng nào trong payload đó tự chạy.
- Phần model thấy và phần hệ thống giữ là hai nhóm trường khác nhau; ranh giới nên là một hàm allowlist, không phải một quy ước trong tài liệu.
- Đặt kiểm quyền (allow/deny/ask) ngoài tool, ở một chặng mà mọi lời gọi đều đi qua.
- Trả lỗi tham số về như kết quả tool để model tự sửa, đừng để nó làm sập vòng lặp.
- Nếu model chạy được thứ nó đề nghị, bạn không có hệ thống agent.
</KeyTakeaways>
