Tự dựng agentic harness bằng Python thuần, từ chat completion tới harness đầy đủ

Dựng agentic harness bằng Python không framework: gọi LLM, tool calling, agentic loop, MCP server, memory bằng file markdown và chuyển giữa các model.

Đã duyệt độc lập · cập nhật 23/10/2026 · 38 min

1. Mục tiêu: tự dựng một agentic harness bằng Python thuần

Tim Ruscica mở đầu bằng lời hứa của video: bạn sẽ học cách build AI agent trong Python bằng cách tự dựng một agentic harness của riêng mình. Anh làm từng mảnh một. Đầu tiên là dựng một agentic loop cơ bản, rồi thêm tool calling, thêm MCP server, thêm persistent memory, và cuối cùng là khả năng làm model routing, tức chuyển qua lại giữa các model (theo video đăng ngày 04/10/2026).

Tất cả viết bằng Python thuần, nghĩa là không dùng bất kỳ framework bên ngoài nào. Tất nhiên code vẫn gọi tới một LLM có sẵn, ví dụ một model của OpenAI, hoặc một model chạy local ngay trên máy bạn (theo video đăng ngày 04/10/2026). Tim nói người xem sẽ học được rất nhiều và thấy mọi mảnh khớp với nhau thế nào trong code thật, thay vì chỉ dùng những thứ như Claude hay ChatGPT. Theo anh, việc này cho bạn một hiểu biết nền tảng về AI agent.

Tim Ruscica nói trước camera, chữ IN PYTHON lớn phía sau
Tim Ruscica ở phần mở đầu, với dòng chữ "IN PYTHON" trên màn hình: toàn bộ harness trong video được viết bằng Python, không dùng framework (video đăng ngày 04/10/2026).

2. Dựng môi trường: VS Code, uv và ba module

Tim mở code editor và nhắc lại rằng sẽ build từng mảnh. Thay vì tự gõ từng dòng code từ đầu, anh chia thành các module khác nhau và copy, paste từng đoạn code vào. Lý do anh đưa ra: trong thời đại này, gần như không còn ai viết code hoàn toàn từ đầu nữa, và anh thật sự không nghĩ việc đó đáng để bạn bỏ thời gian. Anh chia sẻ toàn bộ code của video, đủ từng file riêng, để bạn đi qua từng phần mà không cần gõ lại bằng tay, trừ khi bạn thấy tự gõ giúp mình học tốt hơn (theo video đăng ngày 04/10/2026).

Việc đầu tiên là dựng môi trường. Anh dùng VS Code, và muốn tạo một Python project, cài vài module Python cần thiết. Dù không dùng framework chuẩn nào, ta vẫn cần một module để gửi request chẳng hạn. Sau đó mới bắt đầu dựng các mảnh, khởi đầu bằng một lệnh gọi agent đơn giản, tức chạy được một model bằng code Python (theo video đăng ngày 04/10/2026).

Anh gợi ý: mở một editor bất kỳ (anh dùng VS Code, bạn dùng gì cũng được), mở một folder mới trống, rồi vào terminal. Folder của anh tên là agent-harness-build; bạn chỉ cần mở một folder trống để toàn bộ project nằm một chỗ. Từ đó gõ (video đăng ngày 04/10/2026):

uv init .

Theo Tim, uv là một công cụ quản lý dependency và virtual environment cho Python; anh rất khuyên dùng, và nói nó rất dễ cài (video đăng ngày 04/10/2026). Lệnh uv init . tạo một Python project mới. Anh xoá file main.py vì không cần, và xoá luôn file readme. Rồi anh cài ba module cần dùng:

uv add mcp openai pydantic

Tim nói anh không coi ba thứ này là framework để build AI, vì ta vẫn tự viết mọi thứ gần như hoàn toàn từ đầu. Chúng chỉ giúp kết nối tới MCP server, kết nối tới OpenAI để thật sự dùng được một LLM (trừ khi bạn chạy model local trên máy mình), và Pydantic để khai báo kiểu dữ liệu trong Python (theo video đăng ngày 04/10/2026). Cài xong, các module được thêm vào project, virtual environment được tạo, và mọi thứ sẵn sàng.

3. Lộ trình v0 tới harness đầy đủ, và harness nghĩa là gì

Bước đầu tiên là dựng một lệnh gọi đơn giản tới LLM để hoàn thành một chat completion. Tim giải thích: về cơ bản đó là đưa cho nó một prompt và nhận lại một câu trả lời. Từ nền đó ta xây dần lên: thêm tool calling, thêm memory, thêm model routing, rồi build harness đầy đủ (theo video đăng ngày 04/10/2026).

VS Code với terminal vừa chạy uv add mcp openai pydantic và danh sách bốn mục: add tool calling, add memory, add model routing, build the full harness
Terminal vừa chạy uv add mcp openai pydantic trong folder agent-harness-build, kèm danh sách các bước sẽ làm: add tool calling, add memory, add model routing, build the full harness (video đăng ngày 04/10/2026).

Khi nói "harness", Tim muốn nói tới việc tạo một phiên bản riêng của những thứ như Claude Code hay Codex của OpenAI. Tất nhiên bản của ta nhỏ hơn nhiều, vì chỉ viết trong một video ngắn, nhưng nó cho bạn hiểu các AI agent này thật sự hoạt động thế nào (video đăng ngày 04/10/2026).

Anh tạo file mới tên v0.py. Kế hoạch là bắt đầu với v0, rồi tới v1, v2, v3, mỗi bản thêm một khả năng, để anh có thể giải thích từ từ chuyện gì đang xảy ra.

4. LLM là gì: bộ não chỉ biết nhận text và trả text

Trước khi vào code, Tim giải thích nhanh LLM thật ra là gì và nó liên quan thế nào tới thứ ta sắp build. Một LLM, tức large language model, về bản chất chỉ là thứ có thể dự đoán text. Bạn đưa một prompt, nó trả lại một đoạn text. Anh nhấn mạnh: hãy nhớ điều đó, vì đó đúng là tất cả những gì nó làm. Bạn đưa input, nó đưa output (theo video đăng ngày 04/10/2026).

Output đó thường rất thông minh. Đó là text rất tốt; nó có thể bảo bạn làm gì đó, nó có thể ở dạng JSON. Nhưng rốt cuộc vẫn chỉ là text. Nghĩa là nếu muốn tạo một agent, ta dùng LLM như bộ não của agent, nhưng phải đặt rất nhiều thứ xung quanh nó: khả năng gọi tool, lưu memory, đổi model, làm bất cứ việc gì agent cần làm, như duyệt web chẳng hạn. Bản thân LLM không làm được những việc đó. Bạn chỉ đưa text vào và nhận text ra (theo cách Tim giải thích trong video đăng ngày 04/10/2026).

Vì vậy, khi nói về agentic harness, ta đang nói về tất cả những mảnh được xây quanh LLM để nó hành xử như một agent và thật sự hoàn thành được việc. Cách làm là: ta có một LLM, LLM này đóng vai bộ não. Ta nói cho nó biết tất cả những gì nó có thể làm. Rồi ta bảo nó: nếu muốn gọi một tool, hãy nói với tôi là bạn muốn gọi tool, tôi sẽ gọi giúp bạn và đưa kết quả lại (video đăng ngày 04/10/2026).

Điểm mấu chốt Tim lặp lại: bản thân LLM không gọi tool. Nó không gọi được function, không duyệt web được, nhưng nó có thể nói rằng nó muốn làm những việc đó. Nếu nó muốn, code của ta sẽ thực hiện, lấy kết quả, đưa lại cho LLM, và để LLM đưa ra thêm quyết định. LLM vận hành mọi thứ, nhưng code của ta là lớp vỏ quanh bộ não, cho nó tay, cho nó chân, cho nó các tính năng. Anh so sánh: hãy nghĩ LLM như động cơ, còn harness là chiếc xe bao quanh động cơ đó, và đó chính là thứ ta sắp build (theo video đăng ngày 04/10/2026).

Harness = chiếc xe (code Python của ta) LLM = động cơtext vào, text ra Code gọi tool thậtfile, web, API... "tôi muốn gọi tool X" kết quả của tool X LLM chỉ nói muốn làm gì; harness làm thật rồi đưa kết quả lại
Hình 4.1Hình ảnh Tim dùng: LLM là động cơ chỉ biết nhận và trả text; harness là chiếc xe quanh nó, thực thi những gì LLM nói muốn làm và đưa kết quả trở lại (video đăng ngày 04/10/2026).

5. v0: gọi LLM bằng chat completion

Đương nhiên, ta cần nói chuyện được với bộ não, nên đó là script đầu tiên. Tim hy vọng phần trên đã cho bối cảnh quan trọng, và nói mọi thứ sẽ rõ hơn khi đi tiếp. Anh paste đoạn code vào, đi qua từng dòng, và giải thích cách dùng một model có sẵn hoặc chạy model local trên máy. Đoạn code này chỉ làm một việc: giao tiếp với bộ não, tức LLM (theo video đăng ngày 04/10/2026).

import sys

sys.stdout.reconfigure(encoding="utf-8")  # so emoji don't crash the Windows console

from openai import OpenAI

# One client. Point it at any OpenAI-compatible server (Ollama here).
client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")
MODEL = "qwen3.5:4b"


def chat(user_message: str) -> str:
    response = client.chat.completions.create(
        model=MODEL,
        messages=[
            {"role": "system", "content": "You are a helpful personal assistant."},
            {"role": "user", "content": user_message},
        ],
    )
    return response.choices[0].message.content or ""


if __name__ == "__main__":
    print(f"v0 assistant ({MODEL}) - ctrl+c to quit")
    try:
        while True:
            question = input("\nyou: ")
            print("\nassistant:", chat(question))
    except (EOFError, KeyboardInterrupt):
        print("\nbye!")
File v0.py trong VS Code với client OpenAI trỏ tới localhost:11434 và hàm chat
File v0.py: một client duy nhất trỏ tới server tương thích OpenAI chạy local (Ollama), model qwen3.5:4b, hàm chat và vòng lặp nhập câu hỏi ở cuối file (video đăng ngày 04/10/2026).

Đầu file, anh import sys. Dòng sys.stdout.reconfigure chỉ cần nếu bạn dùng Windows; dùng Mac thì xoá đi được. Nó chỉ để emoji không làm crash console Windows của anh. Rồi anh import package OpenAI, vì nó giúp gửi request tới một LLM và nhận câu trả lời rất dễ (theo video đăng ngày 04/10/2026). LLM có thể nằm trên một máy khác, hoặc ngay trên máy của ta, và ta cần giao tiếp với nó, bảo nó: hãy sinh một câu trả lời, đưa ta ít text.

Ở đây Tim khai báo rằng anh muốn dùng một LLM local trên chính máy mình; anh sẽ nói kỹ ngay sau. Theo anh, cách này nghĩa là ta không cần dependency bên ngoài như API key của OpenAI hay một model GPT nào đó; ta chỉ tải model về và tự chạy local (video đăng ngày 04/10/2026).

Tiếp theo là hàm chat. Tất cả những gì nó làm là gửi một message tới LLM và đưa lại câu trả lời. Cách hoạt động khá thẳng: ta đã định nghĩa client, định nghĩa model, tạo hàm chat nhận một string và trả một string, rồi sinh câu trả lời. Dòng client.chat.completions.create là cách làm riêng của module OpenAI; nó gửi request tới LLM và đưa lại response (theo video đăng ngày 04/10/2026). Ta chỉ định model muốn gọi, rồi chỉ định các message muốn gửi để model sinh câu trả lời.

Tim nói rõ: danh sách messages này thực chất là toàn bộ lịch sử hội thoại. Có ba loại message ta có thể gửi:

  • System message: giống một system prompt, hoặc thông tin chung mà model nên biết. Thường ta chỉ gửi một system message, nhưng gửi nhiều cũng được. Ở đây nó nói: "You are a helpful personal assistant". Đây là chỗ đưa bối cảnh và hiểu biết không đến từ người dùng (theo video đăng ngày 04/10/2026).
  • User message: thứ ta hỏi LLM, kiểu "hôm nay bạn thế nào?", "thời tiết ra sao?", hay "làm việc này đi" (video đăng ngày 04/10/2026).
  • Assistant message: một loại tuỳ chọn khác, là những gì chính LLM đã nói. Ví dụ người dùng hỏi "bạn khoẻ không?", LLM trả lời "tôi khoẻ", và ta cứ thế đi tiếp chuỗi hội thoại (theo video đăng ngày 04/10/2026).

Ngoài ra còn có kết quả của tool call, sẽ gặp ở phần sau: về cơ bản là kết quả một lần gọi tool mà ta đưa cho LLM, để nó có bối cảnh đó trước khi đi tiếp (video đăng ngày 04/10/2026).

Ý tưởng là đưa một danh sách message đại diện cho toàn bộ context, tức cuộc hội thoại đã diễn ra. Thường thì message cuối cùng, tức user message gần nhất, là thứ LLM sẽ trả lời. Đó là cách tạo completion (theo video đăng ngày 04/10/2026).

Rồi tới response.choices[0].message.content. Dòng này nói: hãy nhìn vào response và đưa tôi phần text trả lời, vì model trả về đủ loại thông tin: dùng bao nhiêu token, sinh ra tool call nào (theo video đăng ngày 04/10/2026). Nhưng phần lớn trường hợp ta chỉ cần text, nên dòng này lấy đúng phần đó. Nếu vì lý do nào đó không có text, ta trả một string rỗng.

Cuối file là đoạn driver code siêu đơn giản: in ra cách thoát, hỏi người dùng nhập gì đó, họ nhập, rồi ta trả câu trả lời bằng cách gọi hàm chat. Theo Tim, script này chỉ có vậy.

6. Chọn model: API key của OpenAI hay model local qua Ollama

Trước khi chạy, Tim giải thích phần URL của Ollama. Khi chạy LLM, bạn có nhiều lựa chọn. Lựa chọn thứ nhất, và theo anh có lẽ dễ nhất, là tạo một API key của OpenAI. Anh nói thật là bạn tạo loại API key nào cũng được, nhưng với đa số người xem, theo cách này sẽ dễ làm theo nhất (video đăng ngày 04/10/2026). Bạn vào đúng trang platform.openai.com/api-keys, tạo key mới, rồi gán api_key bằng key đó và xoá base_url đi. Xoá base_url, đặt API key bằng key lấy từ trang API, thì code sẽ chạy bình thường.

Bình thường bạn sẽ lưu key trong environment variable, nhưng cho mục đích demo thì để thẳng cũng được. Lý do cách này chạy được: mặc định, thư viện OpenAI giao tiếp với server của OpenAI, nên nó phục vụ một LLM của OpenAI như GPT, bản nào bạn muốn dùng. Bạn chỉ cần đặt API key, chi phí được tính vào key đó, là xong (theo video đăng ngày 04/10/2026). Trong trường hợp của Tim, anh muốn dùng Ollama, nên đã đổi code thành như trên. Và nhớ là nếu đổi sang ChatGPT, bạn phải sửa model thành một trong các model có sẵn của họ.

Đó là cách dùng model có sẵn của OpenAI, cách dễ nhất. Ngược lại, Tim biết nhiều người xem thích chạy model local, và đó là thứ anh đang làm. Đầu tiên phải cài một phần mềm tên là Ollama: vào ollama.com, tải công cụ về và chạy trên máy. Sau đó mở terminal và gõ lệnh ollama; nếu có output hiện ra thì nghĩa là đã cài đúng (theo video đăng ngày 04/10/2026).

Cài xong, bạn cần pull một model local để code dùng được. Gõ ollama list sẽ hiện danh sách mọi model bạn đã tải; trên máy Tim có cả loạt. Trước khi chạy được model nào thì phải tải nó về. Còn chọn model nào để tải? Theo Tim, bạn có thể vào thẳng ollama.com/search, cuộn qua danh sách và tìm một model vừa với lượng VRAM máy bạn có (video đăng ngày 04/10/2026).

ollama list
Cửa sổ PowerShell chạy ollama list với danh sách model local, rồi gõ ollama pull qwen3.5:4b
Kết quả ollama list trên máy Tim: qwen3.5:4b, qwen3.5:2b và nhiều model khác cùng dung lượng. Dòng cuối là lệnh anh gõ để pull model dùng trong video (video đăng ngày 04/10/2026).

Tim không muốn mất nhiều thời gian cho chuyện này, vì kênh của anh có rất nhiều video về chạy model local. Cho video này, anh khuyên: nếu phần cứng của bạn ở mức khá, tức có card đồ hoạ rời (kể cả card cấp thấp trên máy Windows), hoặc bất kỳ máy Mac dòng M nào như M1, M2, M3, M4, M5, thì cứ pull đúng model anh dùng, là Qwen 3.5 bản 4 tỷ tham số. Nếu chạy quá chậm, hãy pull Qwen 3.5 bản 2 tỷ tham số (lời khuyên theo video đăng ngày 04/10/2026).

Cách pull: gõ ollama pull ("nếu tôi gõ đúng được", anh nói trong lúc gõ), rồi paste tên model muốn pull. Bạn chỉ cần viết đúng như thế này. Máy mạnh hơn thì dùng bản 4 tỷ (theo video đăng ngày 04/10/2026).

ollama pull qwen3.5:4b

Còn nếu phần cứng của bạn thật sự rất yếu, kiểu đang dùng điện thoại hay gì đó, Tim bảo đừng mất công làm cách này. Cứ dùng API key của OpenAI, vì theo anh nó chạy tốt hơn nhiều và nhanh hơn. Đúng là phải trả một ít tiền, nhưng trên phần cứng yếu thì các model này thật sự sẽ không chạy nổi (đánh giá của Tim, video đăng ngày 04/10/2026). Tóm lại: nếu phần cứng khá, pull một trong hai model, bản 4 tỷ hoặc 2 tỷ. Bạn tất nhiên có thể chọn model lớn hơn nhiều, nhưng hai bản này đủ nhỏ để hầu hết mọi người chạy được, theo Tim, và rồi bạn giao tiếp được với chúng.

7. Chạy v0 với model local

Tim nhắc lại hai đường. Nếu dùng Ollama: pull một model, có thể tìm trong trang search để chọn. Nếu dùng API key của OpenAI: chỉ cần xoá base_url, đặt API key bằng key thật của bạn, và đổi model thành model GPT bạn muốn dùng. Vì anh dùng Ollama nên để nguyên code. URL localhost trỏ tới server Ollama local, còn đây là model; nếu bạn đổi model thì sửa dòng này (theo video đăng ngày 04/10/2026).

Anh chạy code. Chờ một chút, chương trình hỏi người dùng nhập gì đó, hiện chữ "you" khi đã nạp xong. Anh gõ "hello" và nói sẽ nhận câu trả lời khá nhanh khi model nạp xong. Anh dặn: câu trả lời đầu tiên luôn mất một lúc vì Ollama phải nạp model; nạp xong thì sẽ nhanh hơn (theo video đăng ngày 04/10/2026).

Trên màn hình, terminal in dòng "v0 assistant (qwen3.5:4b) - ctrl+c to quit". Với câu "hello", model trả lời "Hello there! How can I assist you today? Feel free to ask me anything!", kèm một emoji vẫy tay (demo trong video đăng ngày 04/10/2026).

Anh gõ tiếp "I am good, how about you?" và chờ. Câu trả lời hiện ra. Tim thừa nhận nó không nhanh như anh kỳ vọng, nhưng điểm chính là nó chạy, và giờ ta dùng được model local. Muốn thoát thì bấm Ctrl+C (video đăng ngày 04/10/2026).

you: helloterminal chat(user_message)system + user message localhost:11434/v1Ollama, qwen3.5:4b một lượt hỏi, một lượt trả lời: chưa có tool, chưa có vòng lặp
Hình 7.1v0 chỉ là một chat completion: câu hỏi đi qua hàm chat tới server tương thích OpenAI chạy local, và text quay về terminal.

8. v1: thêm tool, những hàm Python bình thường

Tới đây, Tim hy vọng ta đã hiểu cách giao tiếp với model. Giờ chuyển sang tool calling. Anh lại paste code vào, và trấn an: trông sẽ nhiều, nhưng anh sẽ chia nhỏ ra giải thích (theo video đăng ngày 04/10/2026).

Phần mở đầu gần như y hệt: vẫn có OpenAI, vẫn giao tiếp với service Ollama ở localhost, vẫn có dòng import sys. Điểm mới là anh import thêm json và pathlib (video đăng ngày 04/10/2026).

import json
from pathlib import Path

from openai import OpenAI

client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")
MODEL = "qwen3.5:4b"

WORKSPACE = Path(__file__).parent / "workspace"


# --- the actual tools: plain Python functions ---------------------------------

def list_files() -> str:
    """List the files in the assistant's workspace folder."""
    return "\n".join(p.name for p in WORKSPACE.iterdir()) or "(empty)"


def read_file(filename: str) -> str:
    """Read a file from the workspace folder."""
    path = WORKSPACE / filename
    if not path.is_file():
        return f"error: no file named {filename}"
    return path.read_text(encoding="utf-8")


TOOLS = {"list_files": list_files, "read_file": read_file}

Anh định nghĩa một thư mục workspace, rồi tạo luôn một thư mục workspace trống ngay trong project. Lý do sẽ rõ ngay sau; nếu muốn làm theo, hãy nhớ tạo thư mục này, vì đây là nơi ta làm việc khi nói tới AI agent và tool calling. Phần bên dưới về cơ bản vẫn là những thứ đã thấy, chỉ chỉnh lại một chút (theo video đăng ngày 04/10/2026).

Điều ta đang làm là thêm tool cho agent. Theo Tim, thứ làm agent hữu ích là khả năng làm việc, và làm việc thực chất là gọi tool. Có nhiều loại tool khác nhau, nhưng khi tự viết agent, tool thường chỉ là các hàm Python, và hàm đó làm được bất cứ gì ta muốn (theo video đăng ngày 04/10/2026).

Những tool phổ biến bạn sẽ thấy thường liên quan tới ghi file, sửa file và tương tự. Nhưng bạn hoàn toàn có thể có một tool tương tác với browser, gọi một API, hay tắt đèn nhà bạn. Nó làm được đúng bất cứ thứ gì bạn muốn, theo lời Tim. Trong ví dụ này anh viết hai tool, đều là code Python bình thường (video đăng ngày 04/10/2026):

  • list_files: liệt kê các file trong thư mục workspace.
  • read_file: đọc một file trong thư mục workspace.

Rồi anh map các tool: trong dict TOOLS, khoá "list_files" trỏ tới hàm list_files, tức mỗi tool có một cái tên; "read_file" cũng vậy, trỏ tới hàm read_file.

9. Tool schema: cho model biết nó gọi được gì

Tới đây, Tim nhắc lại điều đã nói: LLM không thật sự gọi được tool. Thứ nó làm được là yêu cầu gọi một tool, hay đưa ta đoạn text nói rằng nó muốn gọi tool, chứ không tự gọi được. Vì vậy ta cần tạo một thứ gọi là tool schema. Schema này nói cho LLM biết những tool nào có sẵn và cách gọi chúng nếu nó muốn (theo video đăng ngày 04/10/2026).

Theo Tim, đa số framework Python phổ biến như LangChain sẽ tự tạo các schema này cho bạn (video đăng ngày 04/10/2026). Nhưng vì đang viết từ đầu, ta phải tự viết. Ta chỉ định loại của các tool, rồi mô tả mọi tham số, kiểu trả về, phần mô tả, đại loại vậy.

# --- what the model sees: a JSON description of each tool -------------------

TOOL_SCHEMAS = [
    {
        "type": "function",
        "function": {
            "name": "list_files",
            "description": "List the files in the user's workspace folder.",
            "parameters": {"type": "object", "properties": {}},
        },
    },
    {
        "type": "function",
        "function": {
            "name": "read_file",
            "description": "Read one file from the user's workspace folder.",
            "parameters": {
                "type": "object",
                "properties": {"filename": {"type": "string"}},
                "required": ["filename"],
            },
        },
    },
]
Phần TOOL_SCHEMAS trong v1.py mô tả list_files và read_file
Tool schema của v1.py: mỗi tool là một mục "type": "function" với tên, mô tả và tham số. read_file có một tham số bắt buộc là filename kiểu string.

Tim đọc qua: có type là function, mục này khớp với hàm list_files. Ta nói nó là một function, tên là list_files, đây là phần mô tả, và đây là các tham số nó nhận: type: object, properties rỗng. Nghĩa là: hàm này không nhận gì cả, không sao, vậy thôi.

Tiếp theo là một mục function khác cho read_file, với mô tả và tham số: type: object, properties có filename kiểu string, rồi required là filename. Nghĩa là đây là tham số bắt buộc phải truyền. Đó là schema, hay bản map của các hàm, mà ta đưa cho LLM để nó gọi được chúng (video đăng ngày 04/10/2026).

Nếu bạn muốn thêm tool khác hai tool này và đang bối rối không biết viết schema thế nào, Tim khuyên cứ nhờ AI viết giúp. Vào Claude Code, hoặc dùng một model nào đó, kể cả model local, và chỉ cần nói: đây là hàm của tôi, bạn viết giúp một schema để tôi đưa cho LLM được không? Theo anh, nó sinh ra cái này rất nhanh (video đăng ngày 04/10/2026).

10. Hàm chat mới: đọc tool_calls, tự gọi hàm, trả kết quả về

Giờ ta có các tool (hai tool) và có schema, đúng thứ cần. Tiếp theo là hàm chat, đã đổi một chút: các message được tách ra thành biến riêng. Ta sinh response như trước, đưa vào model và messages, nhưng lần này truyền thêm tools. Tim lưu ý: ta không truyền biến TOOLS, mà truyền TOOL_SCHEMAS, vì đó là thứ model nhìn vào để biết cách gọi tool (theo video đăng ngày 04/10/2026).

def chat(user_message: str) -> str:
    messages = [
        {"role": "system", "content": "You are a helpful personal assistant."},
        {"role": "user", "content": user_message},
    ]

    response = client.chat.completions.create(
        model=MODEL, messages=messages, tools=TOOL_SCHEMAS
    )
    message = response.choices[0].message

    # The model didn't want a tool - just answer.
    if not message.tool_calls:
        return message.content or ""

    # Run the tool it asked for and give it the result... ONCE.
    messages.append(message)
    for call in message.tool_calls:
        args = json.loads(call.function.arguments or "{}")
        print(f"  [tool] {call.function.name}({args})")
        result = TOOLS[call.function.name](**args)
        messages.append(
            {"role": "tool", "tool_call_id": call.id, "content": result}
        )

    final = client.chat.completions.create(model=MODEL, messages=messages)
    return final.choices[0].message.content

Khi nhận response, bên trong sẽ có một thuộc tính tên tool_calls. Nó liệt kê, dưới dạng text, mọi tool call model muốn thực hiện. Một lần nữa, bản thân model không gọi được tool; nó chỉ đưa ta text mô tả thứ nó muốn gọi (theo video đăng ngày 04/10/2026). Vậy ta lấy message ra và hỏi: nó có tool call nào không? Nếu không, ta trả về content, tức text output của model. Nếu có, ta phải tự gọi tool, vì ta mới là bên thật sự gọi hàm, lấy kết quả, rồi đưa lại cho model.

Cụ thể: lấy message (tức những gì model vừa nói) và đưa nó vào lại danh sách messages, vì ta cần biết model đã nói gì để tiếp tục phần còn lại của cuộc hội thoại. Rồi xem các tool call; model có thể muốn gọi nhiều tool một lúc, nên ta lặp qua từng cái: for call in tool_calls (video đăng ngày 04/10/2026).

Theo Tim, toàn bộ đoạn trong vòng lặp chỉ để gọi được một hàm Python. Ta đặt args = json.loads(call.function.arguments). Rồi in ra tên tool sắp gọi và các argument. Kết quả bằng TOOLS[...]: nhớ rằng TOOLS là dict ở trên, ta truy cập đúng tool model muốn gọi, lấy ra hàm, rồi gọi nó với mọi argument đã chỉ định. Có kết quả rồi thì đưa lại cho model, với role là tool, tool_call_id và content. Như thế model biết: "à, tôi muốn gọi tool này, giờ bạn đã gọi rồi, đây là kết quả". Rồi nó có thể đi tiếp, gọi nhiều tool hơn, và đi qua agentic loop để thật sự hoàn thành một việc (phần sau sẽ nói; theo video đăng ngày 04/10/2026).

messages+ tools=TOOL_SCHEMAS Modelchỉ trả text message.tool_calls"muốn gọi list_files" TOOLS[name](**args)code của ta gọi hàm thật {"role": "tool", "content": result}
Hình 10.1Vòng tool calling mà Tim mô tả: model nhận input và danh sách tool, nói nó muốn gọi tool nào; code tự gọi hàm, rồi đưa kết quả với role tool trở lại để model quyết định bước kế (video đăng ngày 04/10/2026).

Tim tóm lại luồng của các model này: bạn đưa input, nói cho chúng biết được gọi những tool nào. Nếu muốn gọi tool, chúng trả về một response nói "tôi muốn gọi tool này". Bạn tự gọi tool, tức thật sự thực hiện thao tác, rồi đưa kết quả lại cho model. Model nhìn kết quả và quyết định bước tiếp theo (theo video đăng ngày 04/10/2026).

Nhưng trong bản này, ta chỉ cho model đúng một vòng, nên nó bị giới hạn nhiều trong những gì làm được. Phần sau anh sẽ nói tới agentic loop. Hiện tại: lấy input của người dùng, lấy response, xem nó có muốn gọi tool không; nếu có thì gọi; rồi thêm đúng một lượt response nữa, final = client.chat.completions.create(...). Ta đưa kết quả tool call lại cho model, lấy những gì nó nói, kể cả khi đó lại là một tool call khác, và đưa lại cho người dùng. Cuối file vẫn là đoạn driver code như cũ (theo video đăng ngày 04/10/2026).

11. Chạy v1 và giới hạn một vòng

Tim chạy thử để xem có hoạt động không. Anh mở terminal và hỏi "What files are in my workspace?". Lúc này workspace chưa có gì, nhưng anh muốn xem model có thật sự gọi tool không. Chờ một chút, output hiện ra: nó đã gọi tool, dòng [tool] list_files, rồi trả lời rằng workspace của bạn đang trống, không có file nào, và nếu bạn muốn nó làm gì thì cứ bảo (video đăng ngày 04/10/2026).

Anh định thử tiếp "Create two files in my workspace", rồi dừng lại hỏi: mà ta có khả năng làm việc đó không nhỉ? Tool là gì? À, chỉ có read_file. Thôi bỏ qua, đó không phải điểm chính. Thay vào đó, anh tự tạo một file mới tim.txt với nội dung "hello world", và một file world.txt với nội dung "world" ("tôi chỉ đang làm mấy thứ ngẫu nhiên", anh nói).

Rồi anh hỏi: "Tell me the contents of the files that are in my workspace". Lẽ ra nó phải cố đọc những gì trong workspace, thấy có hai file. Và đúng như Tim muốn cho thấy, nó liệt kê các file rồi muốn gọi thêm một tool nữa, nhưng ta không cho, vì cách dựng hiện tại chỉ chạy đúng một vòng (theo video đăng ngày 04/10/2026).

Terminal: câu hỏi Tell me the contents of the files, dòng [tool] list_files({}) và assistant trả lời trống
Chạy v1.py: sau câu hỏi về nội dung các file, model gọi list_files({}), nhưng dòng assistant: bên dưới trống vì nó còn muốn gọi tool tiếp mà code chỉ cho một vòng (video đăng ngày 04/10/2026).

Anh chỉ vào code: câu trả lời cuối từ assistant hoàn toàn trống. Không có response, không có content, vì ở dòng cuối đó không có message nào để assistant trả lời ta. Thực ra có một tool call mà ta không thấy. Tim không muốn nói dài thêm: ý là model muốn gọi thêm tool, hoặc chưa có kết quả, nên chưa thể đưa ta câu trả lời. Việc cần làm tiếp là dựng thứ gọi là agentic loop (theo video đăng ngày 04/10/2026).

12. v2: agentic loop với while True

Script tiếp theo gần như y hệt ví dụ trước; hầu hết code giữ nguyên. Khác biệt đầu tiên là anh chỉnh tool một chút: có list_files, read_file, và thêm tool write_file. Schema có thêm một mục tương ứng, và tool mới được thêm vào dict. Theo Tim, đó gần như là khác biệt duy nhất ở phần tool.

def write_file(filename: str, content: str) -> str:
    """Write (or overwrite) a file in the workspace folder."""
    (WORKSPACE / filename).write_text(content, encoding="utf-8")
    return f"wrote {filename}"


TOOLS = {"list_files": list_files, "read_file": read_file, "write_file": write_file}

Đi tiếp xuống, anh thêm thứ anh gọi là "agentic looper" (rồi tự sửa: không phải anh đặt tên đó, chỉ là người ta gọi nó như vậy). Nhớ lại ví dụ trước: ta không làm được gì có ý nghĩa, vì ngay khi agent gọi xong một tool là hết lượt. Khi build harness, ta cần dựng một vòng lặp chạy theo những gì agent muốn. Có lúc agent chạy một lần, có lúc 20 lần, có lúc 50 vòng. Đây chính là agentic loop mà có thể bạn đã nghe nói tới (theo cách Tim giải thích, video đăng ngày 04/10/2026).

So với v1: ở v1 không có vòng while, chỉ có chat, tức một lượt qua lại với agent. Giờ ta muốn cho agent lặp tiếp và thực hiện nhiều hành động, bao nhiêu lần tuỳ nó. Nên ta có while True: cứ làm tất cả những việc này cho tới khi một điều kiện nào đó phá vòng lặp (video đăng ngày 04/10/2026).

    # THE agent loop. This is the whole trick.
    while True:
        response = client.chat.completions.create(
            model=MODEL, messages=messages, tools=TOOL_SCHEMAS
        )
        message = response.choices[0].message

        if not message.tool_calls:
            return message.content or ""  # done - it answered

        messages.append(message)
        for call in message.tool_calls:
            args = json.loads(call.function.arguments or "{}")
            print(f"  [tool] {call.function.name}({args})")
            result = TOOLS[call.function.name](**args)
            messages.append(
                {"role": "tool", "tool_call_id": call.id, "content": result}
            )

Trong vòng lặp: lấy response từ model, kiểm tra có tool call không. Nếu không có tool call nào, ta coi là xong: ngay khi agent đưa lại content và không muốn gọi thêm tool, nghĩa là nó đã có câu trả lời cuối, và ta thoát khỏi agentic loop. Ngược lại, ta cứ lặp lại bước cũ: append message, gọi tool nó muốn, lấy kết quả, đưa vào messages, rồi cho model biết kết quả là gì. Thế là vòng lặp tiếp tục. Tim nói: đó đúng là thay đổi duy nhất trong v2.py. Giờ có vòng lặp này, model sẽ cứ đi tiếp (theo video đăng ngày 04/10/2026).

gọi modelmessages + TOOL_SCHEMAS tool_calls?while True không: return contentthoát vòng, đã có câu trả lời có: gọi từng toolappend role "tool" lặp lại: 1 lần, 20 lần hay 50 lần, tuỳ agent
Hình 12.1Agentic loop của v2.py: vòng lặp chỉ dừng khi model trả lời mà không đòi gọi thêm tool; số vòng do agent quyết định (video đăng ngày 04/10/2026).

13. Chạy v2: nhiều tool call và file combined.txt

Tim chạy lại cùng ví dụ. Lần đầu script không chạy, anh không biết vì sao, nên mở lại terminal và chạy lại. Chờ một chút, rồi anh gõ: "List all the files in my workspace and tell me the contents of them, and then make a new file that has the combined contents". Anh nói không biết có chạy được không, nhưng ít nhất nó sẽ in ra các tool call (demo trong video đăng ngày 04/10/2026).

Lần này có nhiều tool call liên tiếp, và nó đã tạo ra file combined.txt. Tim chỉ ra: nó lặp nhiều lần vì giờ đã nằm trong agentic loop. Đó là lý do ta cần vòng lặp: phải cho agent cứ lặp cho tới khi cuối cùng nó đưa ra câu trả lời, và vòng lặp chạy số lần khác nhau tuỳ những gì agent muốn làm. Đó là v2 (theo video đăng ngày 04/10/2026).

VS Code mở combined.txt với hai dòng hello world và world, terminal liệt kê hai file và báo đã tạo combined.txt
Kết quả v2.py: assistant liệt kê hai file tim.txt ("hello world") và world.txt ("world"), rồi báo đã tạo combined.txt; file mới mở ở trên với đúng hai dòng đó (demo trong video đăng ngày 04/10/2026).

Tiếp theo là v3, phần nói về MCP. MCP là viết tắt của Model Context Protocol. Theo Tim, đây là một framework hay protocol mở, cho phép cắm gần như bao nhiêu tool cũng được vào một AI agent có sẵn và để agent gọi chúng (video đăng ngày 04/10/2026). Anh nói chắc bạn đã thấy MCP server trong Claude Code hay Codex, và bạn hoàn toàn có thể tự viết MCP server khá dễ; kênh của anh có tutorial về chuyện đó.

Nhưng trong ví dụ này, anh muốn cho thấy cách gọi MCP server từ agentic harness của mình, tức từ code Python. Theo Tim, khi bạn đưa cho Claude Code một MCP server, đằng sau nó làm gần như đúng điều anh sắp cho xem, thứ ta có thể làm thủ công bằng Python (video đăng ngày 04/10/2026). Ý tưởng là không cần hardcode tool vào harness: harness có thể kéo thêm tool từ MCP, khám phá chúng một cách động trong lúc chạy, và gọi khi cần.

14. v3: kết nối MCP server từ Python

Tim báo trước phần này phức tạp hơn một chút do cách MCP được thiết kế, nên mong người xem kiên nhẫn. Anh lại đi qua code, và theo anh, code đầy đủ có sẵn để bạn tải về tự thử (video đăng ngày 04/10/2026).

Để gọi MCP, anh import package mcp, thứ giúp việc này dễ hơn một chút. Anh cũng import AsyncExitStack và AsyncOpenAI. Async là asynchronous: vì MCP có thể là một thao tác qua mạng, hay một thao tác file, IO-bound, ta cần chuyển sang lập trình bất đồng bộ để chờ response của MCP quay về mà không chặn toàn bộ chương trình (theo giải thích của Tim, video đăng ngày 04/10/2026).

from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
from openai import AsyncOpenAI

client = AsyncOpenAI(base_url="http://localhost:11434/v1", api_key="ollama")
MODEL = "qwen3.5:4b"

WORKSPACE = Path(__file__).parent / "workspace"

# Each server is just a command to launch. Same format Claude Code uses.
MCP_SERVERS = {
    "time": StdioServerParameters(command="uvx", args=["mcp-server-time"]),
    "fetch": StdioServerParameters(command="uvx", args=["mcp-server-fetch"]),
}
v3.py khai báo client AsyncOpenAI và dict MCP_SERVERS gồm time và fetch chạy bằng uvx
v3.py: client chuyển sang AsyncOpenAI, và dict MCP_SERVERS khai báo hai server stdio, mcp-server-time và mcp-server-fetch, mỗi cái chỉ là một lệnh uvx để khởi chạy. Comment trong code ghi đây là cùng định dạng Claude Code dùng (video đăng ngày 04/10/2026).

Rồi ta định nghĩa các MCP server. Có nhiều loại MCP server; loại dùng ở đây là STDIO, viết tắt của standard input/output, nghĩa là ta chạy một script ngay trên máy mình để phục vụ MCP server đó. Theo Tim, hai MCP server này, miễn là máy bạn đã cài uv, sẽ tự chạy, không cần cài thêm gì (video đăng ngày 04/10/2026). Một cái cho biết giờ trên máy, một cái cho phép fetch nội dung web, kiểu trang web hay gì đó.

Đi xuống dưới, ta vẫn có các tool local, và Tim lưu ý anh không đổi chúng so với bản trước. Có local tools, có local tool schema, những thứ đã quen. Phần mới là mcp_sessions và hàm kết nối tới MCP server. Anh nhắc lại: code ở đây hơi phức tạp do bản chất bất đồng bộ, và đây là một hàm async.

mcp_sessions: dict[str, ClientSession] = {}


async def connect_mcp(stack: AsyncExitStack):
    """Launch each MCP server and merge its tools into TOOL_SCHEMAS."""
    for name, params in MCP_SERVERS.items():
        read, write = await stack.enter_async_context(stdio_client(params))
        session = await stack.enter_async_context(ClientSession(read, write))
        await session.initialize()

        tools = (await session.list_tools()).tools
        for tool in tools:
            mcp_sessions[tool.name] = session
            TOOL_SCHEMAS.append(
                {
                    "type": "function",
                    "function": {
                        "name": tool.name,
                        "description": tool.description,
                        "parameters": tool.input_schema,
                    },
                }
            )
        print(f"  [mcp] connected '{name}': {[t.name for t in tools]}")

Hàm này lặp qua mọi MCP server, cố kết nối tới server và tới session của nó, chờ session khởi tạo (tức nạp xong đàng hoàng), rồi nạp mọi tool mà MCP server đó có. Sau đó, với từng tool, nó tạo một schema để ta gọi được từ model. Tim nói về cơ bản đó là tất cả: code trông rối, nhưng thực chất ta chỉ nạp mọi tool, khám phá những tool nào tồn tại trên MCP server, rồi thêm chúng vào tool schema để model biết cách gọi. Xong thì in ra "MCP server connected" (theo video đăng ngày 04/10/2026).

async def call_tool(name: str, args: dict) -> str:
    """Run a tool - ours directly, or an MCP server's over the protocol."""
    if name in LOCAL_TOOLS:
        return LOCAL_TOOLS[name](**args)
    result = await mcp_sessions[name].call_tool(name, args)
    return "\n".join(c.text for c in result.content if hasattr(c, "text"))

Tới hàm call_tool: nó kiểm tra tool được yêu cầu có nằm trong local tools không; nếu có thì gọi và trả về. Nếu không, nó lấy từ MCP session. Như vậy hàm này gọi được cả tool local lẫn tool MCP, vì với model, tất cả trông như nhau. Tool MCP được gọi hơi khác một chút, rồi ta lấy response, cũng ở định dạng hơi khác. Chỉ vậy thôi (video đăng ngày 04/10/2026).

Agent loop thì trông y như trước, không khác gì ngoài việc giờ nó là async, và có các await cho việc kết nối, lấy response, kết nối MCP server. Trong hàm main cũng thế: kết nối MCP, rồi đi qua vòng lặp, không có gì thay đổi thật sự. Tim kết luận: với MCP, nó thực chất chỉ là một bộ tool khác mà ta cho agent dùng (theo video đăng ngày 04/10/2026).

15. Chạy v3: hỏi giờ, fetch một website

Tim chạy thử. Chương trình nạp một lúc vì phải kết nối tới các MCP server; trên màn hình, cả hai đều báo đã kết nối: time với hai tool get_current_time, convert_time, và fetch với tool fetch; dòng kế cho biết assistant v3 có 5 tool (demo trong video đăng ngày 04/10/2026).

Anh hỏi: "Can you tell me what the current time is?". Model gọi tool MCP get_current_time với múi giờ Asia/Dubai, và trả lời đúng giờ lúc đó, 4:24 chiều. Tim xác nhận là giờ đúng (demo trong video đăng ngày 04/10/2026).

Terminal v3: hai MCP server đã kết nối, câu hỏi giờ hiện tại với tool get_current_time, rồi câu hỏi fetch techwithtim.net
Chạy v3.py: hai dòng [mcp] connected, câu trả lời giờ hiện tại sau tool call get_current_time, rồi yêu cầu fetch techwithtim.net với hai lần gọi fetch: lần đầu không có scheme, lần sau có https:// (video đăng ngày 04/10/2026).

Tiếp theo anh hỏi "Can you fetch techwithtim.net?". Anh thừa nhận không biết chính xác tool fetch hoạt động thế nào, nhưng thấy nó đang xử lý URL: lần gọi đầu không được, nên nó tự sửa URL. Rồi nó trả về mô tả trang trông thế nào, một YouTuber dạy lập trình, đại loại vậy. Nó đã lấy được website của anh và đưa lại câu trả lời (demo trong video đăng ngày 04/10/2026).

Rồi anh thử: "Can you summarize techwithtim.net and put it in a new file in my workspace?". Ngay khi gõ, anh nhận ra có lẽ nó không làm được vì không có tool phù hợp: ở bản này chỉ có read_file hoặc list_files, nên nó không tạo file được. Tuy vậy, anh đoán nó vẫn sẽ tóm tắt. Màn hình cho thấy nó gọi fetch thêm mấy lần, rồi list_files và read_file lần lượt với từng file. Tim nói nó hơi "phát điên" một chút vì nhận ra mình không tạo file được, không biết phải gọi gì hay làm gì. Cuối cùng nó báo rằng không có tool trực tiếp để làm việc đó. Không sao: điểm chính là MCP đang chạy (demo trong video đăng ngày 04/10/2026).

16. v4: persistent memory bằng một file markdown

Ví dụ tiếp theo là thêm persistent memory cho agent. Theo Tim, việc này làm gần như giống hệt tool calling; chủ yếu là cách ta hướng dẫn agent làm việc với memory. Anh nói tất nhiên có nhiều cách thông minh hơn, như dùng RAG hay vector database, nhưng anh chỉ muốn cho xem một ví dụ siêu đơn giản, vì memory thường có trong các harness (video đăng ngày 04/10/2026).

Vấn đề là: rất thường xuyên, các model này, nhất là giữa nhiều lần chạy, sẽ quên hết những gì bạn đã nói. Theo Tim, model chỉ biết đúng context bạn đưa cho nó (video đăng ngày 04/10/2026). Muốn nó nhớ điều gì về bạn, nó phải thật sự thấy điều đó trong cuộc hội thoại. Đó là lý do ở các ví dụ trước ta giữ toàn bộ lịch sử hội thoại: có đủ messages nên nó biết ta đã nói gì tới giờ. Nhưng nếu khởi động lại, tức đóng chương trình rồi mở lại, nó sẽ quên hết. Muốn có memory bền vững, ta phải lưu nó ở đâu đó.

Tim tạo một file memory.md, một file markdown trong thư mục project, và tham chiếu tới nó trong v4.py. Rồi anh tạo hai "skill", tức hai hàm: một để nạp memory, một để lưu memory (theo video đăng ngày 04/10/2026).

def load_memory() -> str:
    if MEMORY_FILE.is_file():
        return MEMORY_FILE.read_text(encoding="utf-8")
    return "(nothing saved yet)"


def save_memory(fact: str) -> str:
    """Append one fact about the user to long-term memory."""
    with MEMORY_FILE.open("a", encoding="utf-8") as f:
        f.write(f"- {fact}\n")
    return f"saved: {fact}"

Vẫn có list_files và read_file. Anh đưa hàm memory, ít nhất là một trong hai, save_memory, vào thẳng danh sách tool. Có tools, có tool schemas, bạn hiểu rồi: ta thêm đúng một tool nữa để lưu memory. Mô tả của tool này trong schema ghi rõ khi nào nên dùng:

"name": "save_memory",
"description": (
    "Save one short fact about the user to long-term memory. "
    "Use whenever you learn something worth remembering: their "
    "name, preferences, projects, recurring tasks."
),
"parameters": {
    "type": "object",
    "properties": {"fact": {"type": "string"}},
    "required": ["fact"],

Giờ ta có thêm một system prompt. Trong system prompt, ta đưa vào toàn bộ thông tin từ memory trước khi bắt đầu chạy, để model tự động có bối cảnh đó trước khi đi tiếp (video đăng ngày 04/10/2026):

SYSTEM_PROMPT = """You are a helpful personal assistant.

Here is what you remember about the user from previous sessions:
{memory}

When you learn a new lasting fact about the user, save it with save_memory."""

Phần còn lại, như lệnh tạo completion, vẫn y như trước. Khác biệt duy nhất là ở đầu, ta có system prompt, trong đó nạp memory rồi chèn vào. Theo Tim, đó là những gì hầu hết các harness đang làm khi có memory; chúng có thể thông minh hơn một chút ở khâu tìm kiếm trong memory, nhưng đây là cách rất dễ để thêm nó (video đăng ngày 04/10/2026).

Anh chạy thử, mở terminal và nói: "Can you remember that my name is Tim and I'm 26 years old?". Nó phải biết lưu vào memory, và anh xem file có được cập nhật không. Màn hình cho thấy tool call save_memory với fact "The user's name is Tim and they are 26 years old.", và file được cập nhật ngay (demo trong video đăng ngày 04/10/2026).

Dòng đầu khi chạy v4.py ghi "v4 assistant (qwen3.5:4b) - memory loaded - ctrl+c to quit", tức memory đã được nạp ngay lúc khởi động (theo video đăng ngày 04/10/2026). Anh đóng chương trình, chạy lại, rồi hỏi "What is my name and age?" để xem nó có tìm được bằng cách lấy thông tin từ memory không. Nó trả lời: tên bạn là Tim và bạn 26 tuổi; tôi đã lưu thông tin đó trong memory. Nó biết vì thông tin nằm trong file, và file được chèn vào mỗi lần chạy.

lần chạy 1"remember my name..." save_memory(fact)tool call memory.md"- The user's name is Tim..." load_memory()chèn vào SYSTEM_PROMPT lần chạy 2 đã biết
Hình 16.1Persistent memory của v4.py: model tự gọi save_memory để ghi một dòng vào memory.md; lần chạy sau, nội dung file được nạp vào system prompt nên model "nhớ" (theo video đăng ngày 04/10/2026).

17. Harness đầy đủ: file cấu hình MCP và danh sách model để chuyển qua lại

Tới đây, theo Tim, ta đã xem gần như mọi thứ liên quan tới việc build một harness. Phần còn lại anh muốn nói là đổi model, thứ ta có thể build vào như một tính năng. Anh đã viết sẵn một script agentic harness đầy đủ, khá lớn. Anh mang nó vào, chạy, cho xem code, và để người xem thấy mọi mảnh khớp với nhau thế nào. Anh nói sẽ không giải thích quá sâu; anh chỉ muốn cho thấy các mảnh ghép lại ra sao, với ý rằng bạn có thể tự build thứ này chứ không chỉ copy toàn bộ code của anh (video đăng ngày 04/10/2026).

Một điều nhỏ trước: anh tạo file mcp_servers.json để đại diện cho các MCP server ta có, giống như trong bất kỳ harness nào, tức một file riêng được nạp lúc runtime (theo Tim, video đăng ngày 04/10/2026). Trong file harness có cấu hình MCP, memory, workspace, thư mục gốc và mọi import:

ROOT = Path(__file__).parent
WORKSPACE = ROOT / "workspace"
MEMORY_FILE = ROOT / "memory.md"
MCP_CONFIG = ROOT / "mcp_servers.json"


# ---------------------------------------------------------------------------
# Pillar 3: the model registry. Every entry is just an OpenAI-compatible
# endpoint. Cloud or local, frontier or free - same client, same harness.
# ---------------------------------------------------------------------------

Rồi tới phần cấu hình model. Tim lưu ý: anh liệt kê một loạt model khác nhau ở đây, có cái từ Anthropic, có cái từ OpenAI. Anh không đặt key của chúng trong environment, nhưng nếu có thì anh sẽ chuyển sang chúng được. Có hai model Qwen local. Đây là ví dụ cách bạn có thể chuyển giữa các model, hay ít nhất là dựng cấu hình với một danh sách model có sẵn. Model đang dùng tên là local, chính là mục đầu tiên. Ngoài ra có local-small, gpt, claude, bạn hiểu ý rồi (cấu hình theo video đăng ngày 04/10/2026).

MODELS: dict[str, ModelConfig] = {
    "local": ModelConfig(
        base_url="http://localhost:11434/v1",
        api_key="ollama",
        model="qwen3.5:4b",
    ),
    "local-small": ModelConfig(
        base_url="http://localhost:11434/v1",
        api_key="ollama",
        model="qwen3.5:2b",
    ),
    "gpt": ModelConfig(
        base_url="https://api.openai.com/v1",
        api_key=os.getenv("OPENAI_API_KEY", ""),
        model="gpt-5.2",
    ),
harness.py với class ModelConfig dựa trên BaseModel của Pydantic và dict MODELS gồm local, local-small, gpt
Danh sách model trong harness.py: mỗi mục là một ModelConfig (class Pydantic) gồm base_url, api_key và tên model. Hai mục local trỏ tới Ollama, mục gpt đọc key từ environment variable (theo video đăng ngày 04/10/2026).

Đi xuống tiếp: có hai hàm load_memory và save_memory. Rồi các tool write_file, read_file, list_files, có local tools, rồi tool schemas với tất cả các tool local. Có system prompt nạp memory, để ta thấy nó là gì; message được format và chèn memory đã nạp vào. Rồi tới cấu hình MCP: MCP sessions kết nối tới mọi MCP server, lần này đọc từ file cấu hình MCP. Tiếp đến là gọi tool, vẫn vậy: hoặc gọi tool local, hoặc dùng tên tool từ MCP sessions.

Rồi tới run_agent. Tim lưu ý: khi chạy agent, ta chỉ định model, rồi có client, và client sẽ thay đổi vì ta có thể đổi model. Sau đó append messages, đi qua mọi thứ liên quan tới tool call, assistant, bạn hiểu rồi: tất cả những gì đã có trước đó (video đăng ngày 04/10/2026).

18. Slash command, demo cuối và lời kết

Phần mới mà người xem chưa thấy là các command. Nếu người dùng gõ một dấu gạch chéo, tức dòng bắt đầu bằng /, thì cho họ gõ /models, /model, /tools, /memory hoặc /quit. Ta đang thêm tính năng vào terminal cho những việc khác nhau họ có thể làm (theo video đăng ngày 04/10/2026).

def handle_command(line: str) -> bool:
    """Returns True if the line was a command."""
    global model_name
    if not line.startswith("/"):
        return False
    cmd, _, arg = line.partition(" ")
    if cmd == "/models":
        for name, cfg in MODELS.items():
            marker = "*" if name == model_name else " "
            print(f" {marker} {name:<12} {cfg.model}  ({cfg.base_url})")
    elif cmd == "/model":
        if arg in MODELS:
            model_name = arg
            print(f"  switched to {arg} ({MODELS[arg].model})")
        else:
            print(f"  unknown model '{arg}' - try /models")
    elif cmd == "/tools":
        for schema in TOOL_SCHEMAS:
            fn = schema["function"]

Rồi có main loop: kết nối MCP, in ra vài thông tin, cho người dùng gõ một lệnh slash, xử lý lệnh slash, gọi tool, chạy agent. Tim chạy thử để xem nó hoạt động thế nào, và nói khá thú vị khi build được thứ này trong một khoảng thời gian ngắn (video đăng ngày 04/10/2026).

Màn hình in "starting harness...", hai MCP server đã kết nối, rồi "harness ready - model: local (qwen3.5:4b), 7 tools, memory loaded" kèm gợi ý gõ /models, /model <name>, /tools, /memory, hoặc cứ trò chuyện. Anh gõ /models: hiện ra nhiều model, trong đó có gpt với gpt-5.2 và claude với claude-sonnet-5. Rồi gõ /model local-small: chương trình báo đã chuyển sang model local-small (qwen3.5:2b). Anh gõ "hi" để xem model nhỏ có trả lời không. Vẫn vậy, lần đầu dùng model thì nó cần nạp. Rồi nó trả lời "Hello! How can I help you today?". Chạy được (theo video đăng ngày 04/10/2026).

Terminal harness: danh sách model gpt và claude, lệnh /model local-small, câu chào và lệnh /tools liệt kê tool local và mcp
Harness đầy đủ trong terminal: /model local-small chuyển model, "hi" nhận câu chào, rồi /tools liệt kê bốn tool [local] (list_files, read_file, write_file, save_memory) và ba tool [mcp] (get_current_time, convert_time, fetch) (video đăng ngày 04/10/2026).

Tiếp theo là /tools: nó liệt kê mọi tool có sẵn. Rồi /memory: đây là file memory. Tim hỏi còn lệnh gì nữa không, à, hoặc cứ trò chuyện. Thế là ta có một harness chạy hoàn chỉnh (demo trong video đăng ngày 04/10/2026).

Anh yêu cầu: "Clean up my workspace and just summarize all of the documents into one text file". Anh hy vọng nó làm được, nhưng không chắc model siêu nhỏ này giỏi tới đâu. Rồi có vẻ nó đã làm: màn hình cho thấy list_files, ba lần read_file cho combined.txt, tim.txt, world.txt, rồi write_file tạo documents_summary.txt. Trong workspace giờ có file tóm tắt tài liệu. Anh không đưa cho nó tool xoá file, nên nó không xoá được gì, nhưng file tóm tắt đã có. Tuyệt, và đó là agentic harness đầy đủ của ta (theo video đăng ngày 04/10/2026).

Terminal harness: yêu cầu Clean up my workspace, chuỗi tool call list_files, read_file, write_file documents_summary.txt và câu trả lời Done
Demo cuối với model nhỏ: một yêu cầu dọn workspace và gom tài liệu thành một file dẫn tới chuỗi tool call, kết thúc bằng write_file tạo documents_summary.txt và câu "Done!". Dòng trên cùng là memory đã nạp từ lần trước (video đăng ngày 04/10/2026).
run_agentagentic loop (async) /model <name>chọn trong MODELS memory.mdvào system prompt tool localfile + save_memory tool MCPmcp_servers.json
Hình 18.1Các mảnh của harness đầy đủ ghép lại: slash command chọn model trong danh sách, memory đi vào system prompt, và agentic loop gọi được cả tool local lẫn tool MCP nạp từ file cấu hình (theo video đăng ngày 04/10/2026).

Tim khép video: tất nhiên anh có thể tự viết từng dòng code từ đầu, nhưng anh nghĩ định dạng này hiệu quả hơn, hợp hơn, và thật sự dạy được kiến thức thay vì để người xem chép lại code. Anh mời mọi người cho biết trong phần bình luận nếu thích cách làm này, hoặc góp ý, vì anh luôn cố cải thiện video, rồi hẹn gặp ở video sau.

Nguồn và liên kết 11 nguồn

  1. Tên gốc: Build Your Own Agentic Harness in Python - Full Tutorial
  2. Sự kiện: YouTube
  3. Video gốc: https://www.youtube.com/watch?v=H5o1P8RMiMw youtube.com
  4. Kênh YouTube Tech With Tim (Tim Ruscica): youtube.com/channel/UC4JX40jDee_tINbkjycV4Sg youtube.com
  5. uv, công cụ quản lý project và dependency cho Python dùng ở bước dựng môi trường: docs.astral.sh/uv (đọc ngày 23/10/2026) docs.astral.sh
  6. Ollama, phần mềm chạy model local trong video: ollama.com; trang tìm model: ollama.com/search (đọc ngày 23/10/2026) ollama.com
  7. Trang model Qwen 3.5 trên Ollama, liệt kê các tag qwen3.5:2b và qwen3.5:4b dùng trong video (đọc ngày 23/10/2026): ollama.com/library/qwen3.5 ollama.com
  8. Thư viện Python của OpenAI (module openai): github.com/openai/openai-python (đọc ngày 23/10/2026) github.com
  9. Pydantic: docs.pydantic.dev (đọc ngày 23/10/2026) docs.pydantic.dev
  10. Model Context Protocol: modelcontextprotocol.io; Python SDK (package mcp): github.com/modelcontextprotocol/python-sdk (đọc ngày 23/10/2026) modelcontextprotocol.io
  11. Hai MCP server dùng trong v3, chạy bằng uvx mcp-server-time và uvx mcp-server-fetch theo README (đọc ngày 23/10/2026): Time · Fetch github.com