Trao đổi về dự án
Trí Tuệ Nhân Tạo

Xây dựng trợ lý tồn kho tra cứu đúng dữ liệu

Xây dựng trợ lý cục bộ trả lời câu hỏi về tồn kho bằng cách tra cứu danh mục, trong khi chương trình Python của bạn vẫn quyết định quyền truy cập và thực hiện việc đọc dữ liệu.

LLM local : Appel proposé → Contrôleur : Outils autorisés → SQLite : Lecture seule.

Hãy hình dung một trợ lý nhỏ được tích hợp vào cửa hàng trực tuyến của bạn. Một khách hàng hỏi: «Tồn kho của REF-000 là bao nhiêu?» Thay vì phải tìm khắp danh mục, họ chờ một câu trả lời ngay lập tức, bằng câu mà họ hiểu được.

Về phát triển web, nhu cầu này có lẽ quen thuộc với bạn: lấy một mã tham chiếu, truy vấn cơ sở dữ liệu rồi hiển thị kết quả. Chúng ta sẽ giữ cơ chế đó, nhưng giao cho mô hình ngôn ngữ việc biến câu hỏi thành một yêu cầu có cấu trúc.

Điểm quan trọng gói gọn trong một câu: mô hình đề xuất một hành động, nhưng mã của bạn quyết định liệu nó có quyền thực hiện hay không. Nó không được cấp terminal hay khả năng viết SQL tự do. Và ngay cả khi có đúng dữ liệu, chúng ta sẽ kiểm tra nó sử dụng dữ liệu đó thế nào trong câu trả lời.

Trong hướng dẫn này, chúng ta xây dựng một tác tử cục bộ có thể tra cứu sản phẩm và tồn kho. Chúng ta sẽ theo dõi đường đi của một câu hỏi, đọc các hàm kiểm soát quyền truy cập, rồi chạy dự án và các kiểm thử. Bạn không cần biết toán học của mô hình ngôn ngữ: biết đọc Python, làm việc với JSON và dùng terminal là đủ để theo dõi.

Tải dự án hướng dẫn đầy đủ.

Kho lưu trữ chứa dự án cần tái tạo. Các tệp của mô hình, còn gọi là trọng số, không được bao gồm: chúng sẽ được tải ở một bản sửa đổi chính xác và kiểm tra trong lúc cài đặt. Chúng ta sẽ dùng lộ trình R3, ở đây là phiên bản dự án được trình bày và kiểm thử.

Mô hình đề xuất JSON; bộ điều khiển xác thực rồi truy vấn SQLite ở chế độ chỉ đọc

Những gì chúng ta xây dựng

Từ biểu mẫu web đến câu hỏi ngôn ngữ tự nhiên

Trong giao diện cổ điển, người dùng nhập mã tham chiếu vào một trường. Ứng dụng của bạn kiểm tra giá trị này, truy vấn cơ sở dữ liệu và hiển thị tồn kho.

Ở đây, người dùng viết một câu. LLM, viết tắt của Large Language Model hay mô hình ngôn ngữ lớn, tạo một đề xuất giúp chương trình của chúng ta tìm được yêu cầu có cấu trúc. Chúng ta không yêu cầu nó ghi nhớ danh mục hay đoán số lượng sẵn có: cơ sở dữ liệu vẫn là nguồn chân thực.

Trong dự án này, một công cụ là thao tác mà mã của chúng ta chấp nhận thực hiện cho mô hình. Đó không phải chương trình mô hình tự nghĩ ra tức thời. Chúng ta đã định nghĩa hai tên công cụ: rechercher_produit và consulter_stock.

Với câu hỏi «Tồn kho của REF-000 là bao nhiêu?», đây là ví dụ về đề xuất tuân thủ hợp đồng. Nó minh họa định dạng mong đợi; không phải bản chép lại của đầu ra đã đo:

{
  "outil": "consulter_stock",
  "arguments": {
    "ref": "REF-000"
  }
}

JSON này có thể đọc đơn giản là: «Tôi muốn dùng công cụ consulter_stock với mã tham chiếu REF-000.» Với một lập trình viên web, nó giống phần thân của yêu cầu gửi tới API. Và như với API, nhận một yêu cầu không có nghĩa là phải chấp nhận nó.

Hành trình đầy đủ của một câu hỏi

Mô hình đề xuất lời gọi này. Sau đó hàm toolcall() kiểm tra tên công cụ và các đối số. Nếu yêu cầu được cho phép, một truy vấn SQL do lập trình viên viết sẽ tra cứu cơ sở dữ liệu. Kết quả của nó trở thành một quan sát: dữ liệu thực sự đã được đọc, ở dạng có cấu trúc.

Quan sát này cuối cùng được gửi đến mô hình để nó diễn đạt một câu trả lời ngắn. Vì vậy chúng ta có ba thứ khác nhau để xem xét: yêu cầu được đề xuất, dữ liệu đã lấy và câu cuối cùng. Sự tách biệt này giúp hiểu lỗi có thể nằm ở đâu.

Chúng ta gọi đây là tác tử vì mô hình đề xuất sử dụng công cụ. Nhưng lộ trình được cố ý giới hạn: một đề xuất, tối đa một lời gọi công cụ, rồi diễn đạt lại. Nó không có vòng lặp trong đó mô hình nối tiếp các hành động đến khi tự quyết định dừng.

Mô hình đề xuất; bộ điều khiển cho phép

Xử lý JSON như dữ liệu đầu vào bên ngoài

Mô hình ngôn ngữ tạo ra văn bản. Prompt là chỉ dẫn được cung cấp để định hướng việc tạo đó. Yêu cầu nó «chỉ dùng hai công cụ này» là hữu ích, nhưng câu nói ấy không thay thế kiểm soát trong chương trình.

Hãy nghĩ đến một biểu mẫu mà trình duyệt vô hiệu hóa nút «Xóa». Bạn vẫn không bỏ kiểm tra phía máy chủ vì thế. Chúng ta áp dụng cùng logic cho các đề xuất của mô hình: chúng phải đi qua mã có thẩm quyền.

Trong agent.py, hàm run() yêu cầu một đối tượng chứa outil và arguments. Nó giải mã JSON, chuyển cho toolcall(), rồi dùng quan sát nhận được để yêu cầu câu trả lời cuối.

Quyết định cho phép hay từ chối nằm trong tools.py. Hợp đồng của nó rất chính xác:

  • Tên công cụ phải là rechercher_produit hoặc consulter_stock, là hai tên duy nhất trong danh sách cho phép, còn gọi là allowlist.
  • Đối tượng cấp cao nhất phải chứa đúng outil và arguments. Trong arguments, chỉ chấp nhận khóa ref.
  • Mã tham chiếu phải là chuỗi khớp REF-[0-9]{3}: tiền tố REF-, tiếp theo ba chữ số, không có ký tự khác.

Như vậy, REF-000 tuân thủ định dạng. REF-12, ref-000 hoặc mã tham chiếu theo sau bởi chỉ dẫn bổ sung thì không. Một khóa thêm «phòng khi cần» không bị bỏ qua: nó làm yêu cầu bị từ chối.

Trong phiên bản này, cả hai tên công cụ được phép đều dẫn đến cùng thao tác đọc các cột ref, name và stock. Vì vậy rechercher_produit không có nghĩa là tìm tự do trong danh mục: nó cũng nhận một mã tham chiếu chính xác.

Vì sao không để mô hình viết truy vấn SQL?

Vì nhu cầu của chúng ta không đòi hỏi điều đó. Chúng ta đã biết cần tra cứu bảng nào và các cột nào. Mô hình chỉ có một giá trị cần đề xuất: mã tham chiếu.

Câu truy vấn được cố định trong mã: SELECT ref,name,stock FROM products WHERE ref=?. Dấu ? là vị trí giữ chỗ; mã tham chiếu được truyền riêng dưới dạng tham số. Nó được xử lý như dữ liệu, không phải một đoạn SQL để thực thi. Đây là nguyên tắc của các truy vấn có tham số được Python ghi lại.

Vì vậy, mô hình không chọn bảng, cột hay lệnh cần chạy. Không có đường nào trong quy trình này cho phép nó thực thi một lệnh shell tùy ý hoặc SQL do chính nó tạo ra.

Một giới hạn thứ hai nằm ở tầng cơ sở dữ liệu: kết nối SQLite được mở ở chế độ chỉ đọc bằng mode=ro. Các lần thử ghi qua kết nối này phải thất bại, ngay cả khi chúng được thực hiện ngoài truy vấn đọc của chúng ta.

Lưu ý về bộ đếm lần gọi

toolcall() cũng nhận tham số count. Nó chấp nhận các giá trị số nguyên 0, 1 và 2, sau đó từ chối 3. Các bộ đếm âm hoặc thuộc kiểu khác cũng bị từ chối.

Tham số này không phải là hạn ngạch tự ghi nhớ các lần gọi trước đó. Trong quy trình của chúng ta, run() gọi công cụ một lần với giá trị ban đầu. Nếu sau đó bạn thêm một vòng lặp, mã gọi toolcall() phải tăng bộ đếm. Gọi hàm nhiều lần nhưng luôn để count=0 sẽ không tiêu hao ngân sách được lưu.

Dữ liệu, giới hạn và khái niệm

Tồn kho bằng không không có nghĩa là không tìm thấy sản phẩm

Tệp data/products.json chứa 60 sản phẩm và 30 truy vấn. Câu hỏi đầu tiên, có mã định danh 0, chính xác là: “Tồn kho của REF-000 là bao nhiêu?”

Trong dữ liệu, REF-000 tương ứng với “Sản phẩm 0”, có tồn kho bằng 0. Dựa trên bản ghi này và mã đọc dữ liệu, quan sát mong đợi là:

{
  "ref": "REF-000",
  "name": "Produit 0",
  "stock": 0
}

Sản phẩm tồn tại. Chỉ là sản phẩm đã hết hàng.

Ngược lại, ba truy vấn trong bộ dữ liệu nhắm tới REF-999, mã không tồn tại trong cơ sở dữ liệu. Trong trường hợp này, công cụ trả về { "found": false }.

Sự khác biệt rất quan trọng: số không là một giá trị đã biết; “không tìm thấy” là không có kết quả. Với một cửa hàng, hai tình huống này không dẫn đến cùng một thông báo. “Chúng tôi đã hết hàng” không có nghĩa là “chúng tôi không biết mã tham chiếu này”.

Cũng hãy lưu ý rằng REF-999 tuân thủ định dạng được cho phép. Vì vậy, bộ điều khiển có thể chấp nhận việc tra cứu dù cơ sở dữ liệu không chứa sản phẩm tương ứng. Xác thực hình thức của mã tham chiếu và kiểm tra sự tồn tại của nó là hai bước khác nhau.

Kiểm tra cơ sở dữ liệu không thay đổi

Chương trình tính giá trị băm SHA-256 của tệp cơ sở dữ liệu trước và sau 30 yêu cầu, rồi yêu cầu hai giá trị phải giống nhau. Giá trị băm được tính từ nội dung tệp; Python cung cấp SHA-256 trong mô-đun hashlib.

Trong thí nghiệm này, đây là một kiểm tra trước/sau: chúng ta muốn tra cứu sản phẩm, không sửa đổi dữ liệu. Sự khác biệt giữa các giá trị băm sẽ cho thấy tệp không còn giống ban đầu.

Kiểm tra này không thay thế chế độ chỉ đọc. Nó so sánh hai trạng thái; không mô tả mọi hành động diễn ra giữa hai thời điểm và không cho biết ai đã truy cập dữ liệu. Chúng ta sử dụng nó bổ sung cho các hạn chế và các kiểm tra từ chối.

Vì sao cần thêm các biện pháp bảo vệ Docker?

Dự án kết hợp ba tầng kiểm soát: môi trường thực thi, kiểm tra Python và kết nối SQLite.

Container ứng dụng có hệ thống tệp chỉ đọc. Các capability Linux của nó, tức các đặc quyền hệ thống riêng, bị loại bỏ. Tùy chọn no-new-privileges cấm tiến trình nhận thêm đặc quyền khi chạy chương trình khác. Một tmpfs, vùng tạm thời trong bộ nhớ, cũng bị giới hạn. Ứng dụng dùng mạng Docker nội bộ; các mount của mô hình và dữ liệu chỉ đọc tùy theo vai trò.

Các cơ chế này giảm những gì ứng dụng có thể làm trong môi trường của nó. Chúng không thay thế việc kiểm soát các công cụ. Để tìm hiểu thêm về các tùy chọn liên quan, hãy xem tài liệu tham chiếu dịch vụ Docker Compose.

“Chỉ đọc” cũng không có nghĩa là thí nghiệm không tạo ra kết quả nào: dự án dành thư mục outputs/ cho các đầu ra của nó. Điều chúng ta bảo vệ ở đây, đặc biệt, là cơ sở dữ liệu mà tác tử tra cứu.

Ba biện pháp kiểm soát bổ sung: container, kiểm tra Python và SQLite chỉ đọc

Mô hình thực sự được sử dụng

Chúng tôi sử dụng Qwen2.5-7B-Instruct-GGUF, biến thể q4, được llama.cpp phục vụ trên mạng Docker nội bộ.

Để giải mã tên này: 7B chỉ một mô hình có khoảng bảy tỷ tham số; Instruct là biến thể được chuẩn bị để làm theo chỉ dẫn; GGUF là định dạng các tệp được engine nạp. Biến thể Q4_K_M là phiên bản lượng tử hóa: nó biểu diễn trọng số với độ chính xác giảm để giảm dung lượng. Các yếu tố này được ghi trong kho chính thức của mô hình.

Mô hình và engine không có cùng vai trò: Qwen cung cấp trọng số; llama.cpp nạp chúng và chạy sinh văn bản. Ứng dụng Python của chúng tôi gửi yêu cầu đến nó. Việc sử dụng một mô hình đã được huấn luyện này gọi là suy luận. Chúng tôi không huấn luyện mô hình trong hướng dẫn này.

Quy trình R3 cố định revision của kho tại bb5d59e06d9551d752d08b292a50eb208b07ab1f. Hai tệp GGUF cũng được khóa bằng SHA-256 trong configs/models.lock.json. Mục tiêu là lấy lại đúng các tệp đó, không phải tự động tải phiên bản mới hơn.

Hàm generate() dùng temperature=0, seed=42 và max_tokens=100. Nhiệt độ điều chỉnh phần ngẫu nhiên trong quá trình sinh; seed cố định một tham số của bộ sinh ngẫu nhiên; max_tokens giới hạn độ dài đầu ra theo đơn vị văn bản gọi là token, không nhất thiết tương ứng với từ hoàn chỉnh. Một schema JSON nghiêm ngặt cũng ràng buộc lần sinh đầu tiên. Máy chủ llama.cpp ghi lại các thiết lập sinh và đầu ra bị ràng buộc bởi schema.

Các thiết lập này mô tả chính xác thí nghiệm. Chúng không chứng minh mô hình luôn chọn đúng tham chiếu, cũng không chứng minh câu trả lời cuối luôn chính xác. Chính vì vậy chúng tôi lưu các quan sát và đo lường kết quả.

Các hàm cốt lõi cần đọc trước khi chạy

Hãy bắt đầu với phần cốt lõi của chương trình. Các đoạn sau trình bày lại logic mã R3 với định dạng thoáng hơn. Đây không phải các tệp độc lập: import, hằng số và các hàm khác nằm trong dự án có thể tải xuống.

Ba hàm chuẩn bị nền tảng. create() tạo cơ sở dữ liệu mới và từ chối ghi đè tệp hiện có. readonly() mở kết nối chỉ đọc. digest() đọc các byte của tệp và tính dấu vân tay SHA-256. Vì vậy một thí nghiệm mới bắt đầu trong bản giải nén mới của kho lưu trữ.

toolcall(): bước bắt buộc trước cơ sở dữ liệu

def toolcall(db, call, count=0):
    if type(count) is not int or not 0 <= count < 3:
        raise ValueError("Budget d’appels épuisé ou invalide")

    if (
        not isinstance(call, dict)
        or set(call) != {"outil", "arguments"}
        or call["outil"] not in TOOLS
    ):
        raise ValueError("Outil interdit ou appel mal formé")

    args = call["arguments"]
    if (
        not isinstance(args, dict)
        or set(args) != {"ref"}
        or not isinstance(args["ref"], str)
        or not re.fullmatch("REF-[0-9]{3}", args["ref"])
    ):
        raise ValueError("Référence ou arguments invalides")

    row = db.execute(
        "SELECT ref,name,stock FROM products WHERE ref=?",
        (args["ref"],),
    ).fetchone()

    return (
        {"found": False}
        if row is None
        else dict(zip(("ref", "name", "stock"), row))
    )

Hãy đọc hàm này từ trên xuống: chừng nào các kiểm tra chưa qua, câu truy vấn SQL chưa được chạm tới.

Đầu tiên, bộ đếm. Kiểm tra type(count) is not int yêu cầu một số nguyên Python chính xác. Vì sao không đơn giản chấp nhận mọi thứ giống số nguyên? Vì trong Python, boolean là kiểu con của số nguyên: True có thể hoạt động như 1. Tài liệu Python về boolean giải thích đặc điểm này. Ở đây boolean không phải bộ đếm hợp lệ.

Tiếp theo, vỏ bọc của yêu cầu. call phải là một từ điển. set(call) lấy tập hợp các khóa của nó: phép so sánh yêu cầu đúng outil và arguments, không thiếu hay thừa khóa. TOOLS chứa các tên được cho phép.

Sau đó, các đối số. Logic tương tự áp dụng cho args, chỉ được chứa ref. re.fullmatch() kiểm tra toàn bộ tham chiếu, không chỉ phần đầu chuỗi.

Cuối cùng, việc đọc. fetchone() lấy một hàng hoặc trả về None khi không có sản phẩm nào khớp. Khi đó câu lệnh cuối trả về { "found": false } hoặc một từ điển gắn các cột ref, name và stock với giá trị của chúng. zip() thực hiện liên kết này trước khi dict() tạo từ điển.

Không bước nào trong số này yêu cầu mô hình tự quyết định quyền của nó. Các quy tắc nằm trong chương trình.

readonly(): để SQLite áp dụng chế độ chỉ đọc

def readonly(path):
    return sqlite3.connect(
        Path(path).resolve().as_uri() + "?mode=ro",
        uri=True,
    )

Path(path).resolve().as_uri() chuyển đường dẫn tệp thành URI. Hậu tố ?mode=ro yêu cầu mở chỉ đọc, và uri=True bảo Python diễn giải ký pháp này. Hoạt động của mode=ro được mô tả trong tài liệu chính thức của SQLite.

Vì vậy đây không chỉ là quy ước kiểu “chúng tôi hứa chỉ thực hiện SELECT”. Bản thân kết nối áp đặt hạn chế. Các kiểm thử âm sẽ thử ghi sau khi mở này và kiểm tra rằng nó thất bại.

run(): nối câu hỏi, công cụ và câu trả lời

def run(db, query):
    start = time.perf_counter()
    proposal = generate(
        query["query"],
        SCHEMA,
        system="Choisis un outil en JSON. Outils autorisés: rechercher_produit(ref), consulter_stock(ref).",
    )
    trace = []
    answer = None
    error = None

    try:
        if proposal["finish_reason"] != "stop":
            raise ValueError("Proposition tronquée")

        call = json.loads(proposal["raw"])
        observation = toolcall(db, call)
        trace.append({"call": call, "observation": observation})

        answer = generate(
            query["query"] + "\nObservation outil: " + json.dumps(observation),
            system="Réponds uniquement avec le stock observé, ou introuvable. Ne devine pas.",
        )
    except (ValueError, TypeError, KeyError) as exc:
        error = str(exc)

    return {
        **query,
        "proposal": proposal,
        "trace": trace,
        "answer": answer,
        "error": error,
        "seconds": time.perf_counter() - start,
    }

Hàm gọi generate() hai lần, nhưng cho hai nhiệm vụ khác nhau. Lần gọi đầu tiên yêu cầu một công cụ ở định dạng JSON. Lần thứ hai yêu cầu một câu dựa trên quan sát mà công cụ đó trả về.

Giữa hai lần gọi, finish_reason phải có giá trị stop. Trong quy trình này, mọi giá trị khác đều khiến đề xuất bị từ chối trước khi đến bộ điều khiển. Kiểm tra này chưa đủ để xác thực nội dung: json.loads() vẫn phải giải mã văn bản, rồi toolcall() phải chấp nhận yêu cầu. Nếu yêu cầu bị từ chối, lần sinh văn bản thứ hai sẽ không chạy.

trace lưu lời gọi thực sự được thực hiện và dữ liệu đã đọc. proposal giữ đề xuất ban đầu, còn error nhận thông báo của các ngoại lệ được bắt. Khi trả về, **query lấy lại các trường của câu hỏi gốc; seconds đo thời gian của chuỗi xử lý kể từ đầu hàm.

Một chi tiết cần chú ý trong đoạn mã: lần gọi generate() đầu tiên nằm trước khối try. Khối này sau đó chỉ bắt ValueError, TypeError và KeyError. Vì vậy không nên hiểu error là sự bảo đảm rằng mọi lỗi có thể xảy ra đều được ghi lại, đặc biệt ở lần gọi đầu tiên. Đây là giới hạn có thể thấy khi đọc mã, không phải một sự cố bổ sung được quan sát trong thử nghiệm.

Cuối cùng, evaluate() sẽ kiểm tra hai điều riêng biệt: độ chính xác của quan sát và sự hiện diện của một dấu hiệu mong đợi trong câu trả lời. Chúng ta sẽ thấy vì sao hai kiểm tra này không có cùng mức độ xác nhận.

Điều kiện cần có và cách chạy

Bây giờ chúng ta có thể chạy dự án khi đã biết mình cần quan sát điều gì.

Lần tái hiện đã được kiểm chứng sử dụng Linux x86_64, Docker/Compose, trình điều khiển NVIDIA và NVIDIA Container Toolkit, trên RTX 3090. README dự trù 12 GiB RAM, 15 GiB dung lượng trống cho đĩa chứa dự án và bộ lưu trữ Docker, cùng 10 GiB VRAM trống. VRAM là bộ nhớ của card đồ họa, khác với RAM của máy. Đây là mức tài nguyên dự trù thận trọng, không phải mức tối thiểu đã đo. Quy trình này chưa xác nhận chế độ chạy bằng CPU.

NVIDIA Container Toolkit cho phép container truy cập card được chọn và các thư viện của trình điều khiển. GPU hiện diện trên máy chủ chưa chắc đã dùng được từ Docker: bước kiểm tra trước, hay preflight, sẽ xác minh điều đó. Script không thay đổi hệ thống của bạn. Về điều kiện cài đặt, hãy tham khảo tài liệu NVIDIA Container Toolkit.

Giải nén ZIP vào một thư mục mới, rồi chuyển đến thư mục chứa compose.yaml. Tất cả lệnh sau đây đều được chạy từ thư mục gốc này.

Để dễ định hướng: app chạy mã Python; server nạp mô hình bằng llama.cpp; models/ lưu các trọng số đã tải; outputs/ nhận kết quả. Chúng ta sẽ chuẩn bị môi trường, tải mô hình, rồi thực hiện và đánh giá các yêu cầu.

1. Kiểm tra môi trường và dựng image

Trước khi sinh bất kỳ nội dung nào, hãy kiểm tra khả năng truy cập GPU từ Docker. Script cấu hình và việc dựng các image sau đó sẽ chuẩn bị môi trường cho dự án.

./scripts/preflight.sh
sh scripts/setup.sh
docker compose build

Điều cần quan sát: không có lỗi preflight hay lỗi dựng image. Nếu nvidia-smi hoạt động trên máy chủ nhưng preflight thất bại trong Docker, môi trường vẫn chưa sẵn sàng cho bài hướng dẫn này.

2. Tải mô hình và chạy các bài kiểm thử công khai

Quá trình tải đặt các tệp vào models/ sau khi kiểm tra kích thước và SHA-256. Cần Internet để dựng image và tải mô hình; sau đó việc sinh nội dung sử dụng các tệp cục bộ.

docker compose --profile acquisition run --rm --no-deps fetch
docker compose run --rm --no-deps app -m unittest discover -s tests -v

Điều cần quan sát: 14 bài kiểm thử thành công. Các bài kiểm thử này xác minh hợp đồng của công cụ và những đầu vào bị cấm trước khi gửi bất kỳ yêu cầu nào đến máy chủ mô hình. Nhờ đó chúng ta kiểm tra quy tắc của chương trình mà không phụ thuộc vào câu trả lời của LLM.

Không được âm thầm thay một tệp có dấu vân tay không khớp bằng một biến thể khác. Để hiểu cơ chế tải, hãy mở scripts/fetch_model.py và docs/MODEL_ACQUISITION.md trong dự án.

3. Chuẩn bị dữ liệu rồi gọi tác nhân

Lệnh prepare tạo cơ sở dữ liệu SQLite và dấu vân tay của nó. Sau đó chúng ta khởi động máy chủ mô hình và chạy tác nhân với 30 truy vấn.

docker compose run --rm --no-deps app -m src.cli prepare
docker compose up -d server
docker compose run --rm --no-deps app -m src.cli agent

Điều cần quan sát: outputs/agent.json chứa kết quả của 30 yêu cầu, và dấu vân tay cơ sở dữ liệu trước/sau giống nhau.

Hãy dành thời gian xem một yêu cầu trong tệp này. Mô hình đã đề xuất mã tham chiếu nào? Công cụ đã trả về quan sát gì? Câu trả lời sau đó chứa gì? Nhật ký giúp bạn theo dõi các bước này thay vì chỉ đánh giá câu cuối cùng.

4. Kiểm tra các trường hợp từ chối và đọc kết quả

Một kiểm thử âm tính xác minh rằng thao tác bị cấm thực sự thất bại. Đây không phải là bài kiểm thử «diễn ra không tốt»: thành công của nó chính là nhận được sự từ chối mong đợi.

docker compose run --rm --no-deps app -m src.cli negatives
docker compose run --rm --no-deps app -m src.cli evaluate

Điều cần quan sát: năm lời gọi sai cấu trúc bị từ chối, một trường hợp vượt hạn mức bị từ chối, một thao tác ghi SQLite bị từ chối, cơ sở dữ liệu không đổi và các kết quả đánh giá dạng JSON.

Những lời gọi thành công cho thấy tác nhân có thể tra cứu dữ liệu của kịch bản. Những lời gọi bị từ chối cho thấy các dạng bị cấm đã thử không vượt qua được kiểm tra. Chúng ta cần cả hai để hiểu hành vi của chương trình.

Hiểu các chỉ số trước khi đọc kết quả

Một câu trả lời có vẻ thuyết phục chưa chắc đã có căn cứ. Để tránh trộn lẫn các bước, việc đánh giá sử dụng nhiều chỉ số.

Chỉ số Kiểm tra hoặc đo lường điều gì Cách đọc
observation_correct Sự bằng nhau giữa quan sát do công cụ trả về và dữ liệu tham chiếu. Đây là kiểm tra trực tiếp nhất đối với dữ liệu được lấy cho yêu cầu.
answer_marker_present Sự hiện diện của số tồn kho mong đợi hoặc từ tiếng Pháp “introuvable” (nghĩa là “không tìm thấy”) trong câu trả lời cuối. Đây là kiểm tra một dấu hiệu, không phải xác thực đầy đủ ý nghĩa của câu.
errors Các lỗi kiểm tra hoặc sinh nội dung được phép đánh giá đếm. Số không mô tả lần chạy này; không chứng minh mọi lỗi có thể xảy ra đều vắng mặt.
mean_ms Thời gian trung bình của một yêu cầu hoàn chỉnh, tính bằng mili giây. Nó áp dụng cho chuỗi được đo, không chỉ truy vấn SQL.

Sự khác biệt giữa hai chỉ số đầu tiên cần một ví dụ. Hãy tưởng tượng câu trả lời hư cấu này: “Tồn kho: 0, nhưng hàng bổ sung sẽ đến vào ngày mai.” Con số mong đợi thực sự xuất hiện. Tuy nhiên, công cụ của chúng ta không cung cấp ngày bổ sung nào. Vì vậy, kiểm tra sự hiện diện của dấu hiệu không đủ để tuyên bố cả câu là đúng.

Ngược lại, việc thiếu dấu hiệu không đủ để giải thích bản chất của thất bại. Cần kiểm tra đầu ra liên quan để hiểu mô hình đã viết gì.

Vì vậy, hãy đọc kết quả theo thứ tự này: dữ liệu được lấy, các lần từ chối mong đợi, giá trị băm của cơ sở dữ liệu, rồi câu trả lời cuối. Chất lượng văn bản không được che giấu một lỗi xảy ra sớm hơn trong quy trình.

Kết quả quan sát được

Các giá trị dưới đây đến từ hai lần tái hiện riêng biệt của quy trình tham chiếu R3, trên 30 truy vấn. Chúng mô tả thí nghiệm này, với dữ liệu, mô hình và môi trường đã trình bày ở trên.

Chỉ số Lần tái hiện 1 Lần tái hiện 2
Truy vấn đã xử lý 30 30
observation_correct 1,00 1,00
errors 0 0
answer_marker_present 0,9667 0,9667
Thời gian trung bình 247,34 ms 247,41 ms

Cần rút ra điều gì? Các quan sát đều đúng trong 30 yêu cầu của mỗi lần tái hiện. Nhưng dấu hiệu mong đợi không xuất hiện trong mọi câu trả lời cuối: 0,9667 tương ứng, sau khi làm tròn, với 29 trên 30 câu trả lời.

Điều này không có nghĩa là “96,67% các câu hoàn toàn đúng”. Đó không phải điều chỉ số này đo. Chúng ta chỉ biết dấu hiệu được tìm kiếm xuất hiện với tần suất đó. Chỉ riêng các kết quả đã công bố không cho phép mô tả cách diễn đạt của câu trả lời không có dấu hiệu.

Thời gian trung bình cũng chỉ thuộc về kịch bản này. Chúng không phải lời hứa về hiệu năng trên một máy khác, cũng không phải phép thử tải với nhiều người dùng đồng thời.

Điều chúng ta đã học được

Chúng ta đã cho mô hình một khoảng tự quyết nhỏ: đề xuất một công cụ và một mã tham chiếu. Chúng ta không trao cho nó quyền thực hiện bất kỳ thao tác nào trên cơ sở dữ liệu.

JSON dùng để trao đổi một yêu cầu với chương trình. Còn việc cấp quyền dựa trên danh sách đóng các công cụ, các đối số chính xác và các kiểm tra trong mã. SQLite bổ sung hạn chế riêng của nó bằng kết nối chỉ đọc.

Thí nghiệm cũng dạy chúng ta tách tính chính xác của công cụ khỏi tính chính xác của cách diễn đạt. Chỉ một quan sát đúng không ngăn được một câu trả lời không phù hợp. Vì vậy cần giữ dữ liệu có cấu trúc để có thể giải thích và kiểm tra câu trả lời.

Cuối cùng, các dấu vân tay giống nhau và kiểm thử âm mô tả những gì đã được kiểm tra trong quy trình này. Chúng không chứng minh tính an toàn của mọi chương trình tái sử dụng ý tưởng đó. Thay đổi công cụ, cơ sở dữ liệu, mạng hoặc đối số sẽ thay đổi hành vi cần đánh giá.

Bài tập chủ động: dự đoán, sửa đổi, chạy, quan sát, giải thích

Đến lượt bạn. Trước khi chạy các lệnh, hãy ghi lại câu trả lời cho ba câu hỏi sau:

  1. Có nên báo REF-000 là không tìm thấy vì tồn kho bằng 0 không?
  2. Có nên mô tả REF-999 là sản phẩm đã hết hàng không?
  3. Nếu một yêu cầu chứa công cụ tên delete, quy tắc cấm thực thi nó phải được áp dụng ở đâu?

Sau đó chạy:

docker compose run --rm --no-deps app -m src.cli exercise
docker compose run --rm --no-deps app -m src.cli negatives

So sánh dự đoán của bạn với các quan sát

Với REF-000, kết quả mong đợi là một quan sát chứa sản phẩm và tồn kho bằng 0. Với REF-999, đó là { "found": false }. Nếu bạn nhầm hai trường hợp, hãy quay lại kiểm tra row is None trong toolcall(): chính việc không có hàng kích hoạt “không tìm thấy”, không phải giá trị tồn kho.

Với delete, quy tắc phải được bộ điều khiển áp dụng trước cơ sở dữ liệu. Một yêu cầu là JSON hợp lệ về cú pháp không trao cho nó bất kỳ quyền thực thi nào.

Tuy vậy cần phân biệt JSON hợp lệ và JSON tuân theo schema. Một schema có thể giới hạn các giá trị có thể có của trường, nhất là bằng liệt kê enum. Do đó nó có thể giúp loại trừ một số tên công cụ. Điều đó không miễn cho toolcall() việc áp dụng quyền tại thời điểm gọi. Các kiểm thử âm ở đây cho thấy sự từ chối phía bộ điều khiển; chúng không chứng minh việc vượt qua schema sinh.

Đổi một từ, rồi giải thích sự từ chối

Bây giờ hãy làm việc trong một bản sao của dự án. Trong src/cli.py, tìm danh sách cases và thay tên delete của một lệnh gọi bằng modifier_stock. Không sửa danh sách công cụ được phép.

Trước khi chạy lại lệnh negatives ở trên, hãy dự đoán kết quả. Điều kiện nào phải từ chối yêu cầu này? Sau đó quan sát hành vi và tìm kiểm tra tương ứng trong toolcall().

Điểm cần hiểu là: bộ điều khiển không cần biết mọi động từ nguy hiểm. Nó chỉ chấp nhận những tên được cho phép rõ ràng. Đổi tên một hành động bị cấm không làm nó lọt vào danh sách này.

Thay đổi này là một thí nghiệm đề xuất cho người đọc, tách biệt với quy trình tham chiếu. Chúng tôi không công bố phép đo nào cho nó.

Khắc phục sự cố và dọn dẹp

Máy chủ mô hình chưa sẵn sàng. Hãy xem nhật ký dịch vụ server trong Docker, rồi kiểm tra preflight và VRAM khả dụng. Máy chủ phải đã nạp mô hình để xử lý yêu cầu.

Một cơ sở dữ liệu đã tồn tại. Hãy bắt đầu lại từ bản giải nén mới của kho lưu trữ. create() cố ý từ chối ghi đè tệp hiện có: không được bỏ kiểm tra này chỉ để làm thông báo biến mất.

Một yêu cầu bị từ chối. Hãy xem dạng chính xác của nó. Một khóa bổ sung, tham chiếu sai định dạng hoặc bộ đếm không phải số nguyên đều đủ gây từ chối. Trước hết hãy tự hỏi liệu sự từ chối có đúng hợp đồng không, thay vì tự động coi đó là lỗi.

Sau bài tập, hãy dừng các dịch vụ:

docker compose down

Các container và mạng của dự án được xóa. Mô hình và đầu ra vẫn còn trên đĩa. Để bắt đầu lại từ đầu, chỉ xóa thư mục giải nén bạn đã tạo và không còn cần nữa. Không xóa volume hay image dùng chung khi chưa biết chủ sở hữu.

Từ hướng dẫn đến ứng dụng thực tế

Bộ điều khiển của chúng tôi bao phủ một cơ sở SQLite, hai tên công cụ và hợp đồng đối số chính xác. Đây là phạm vi giảng dạy, không phải ứng dụng sẵn sàng mở cho mọi khách hàng của bạn.

Nó còn thiếu xác thực, quyền theo từng người dùng, mã hóa, nhật ký kiểm toán thực sự và giới hạn tốc độ tổng thể. Quy trình cũng không tuyên bố ngăn được mọi rò rỉ dữ liệu.

Hãy lấy một mở rộng cụ thể: hai khách hàng của một ứng dụng không nhất thiết phải xem cùng sản phẩm. Một tham chiếu có thể hoàn toàn đúng định dạng và công cụ được phép, nhưng vẫn nhắm đến tài nguyên mà người dùng không có quyền. Cho phép công cụ và cho phép truy cập tài nguyên là hai quyết định khác nhau.

Trước khi triển khai, hãy liên kết danh tính người gọi với quyền trên từng tài nguyên. Ghi lại các quyết định cấp quyền, cô lập bí mật, kiểm tra lỗi mô hình và yêu cầu rà soát các hành động có tác động. Chỉ đọc bảo vệ trước một số sửa đổi; tuy nhiên nó không cho phép tiết lộ mọi dữ liệu.

Để đi xa hơn

Một mở rộng đầu tiên là thêm công cụ đọc thứ ba. Hãy bắt đầu bằng việc xác định đối số, quyền phía máy chủ và kiểm thử âm của nó trước khi cung cấp cho mô hình. Mở rộng này không thuộc các kết quả R3 trình bày ở đây.

Tiếp theo hãy đưa vào danh tính người gọi và xác minh rằng nó giới hạn các tham chiếu có thể tra cứu. Giữ quan sát có cấu trúc để phân biệt dữ liệu nguồn, quyết định cấp quyền và câu trả lời của mô hình.

Khi đó bạn đã mở rộng cơ chế thiết yếu của hướng dẫn này: để mô hình làm việc với ứng dụng của bạn mà không nhầm lẫn điều nó yêu cầu với điều nó được phép làm.

Các nguồn chính đã tham khảo

Dữ liệu, lệnh, đoạn mã và kết quả trình bày tương ứng với dự án R3. Các tài liệu tham khảo dưới đây giải thích cơ chế được dùng; chúng không thay thế các phiên bản và tệp đã khóa của dự án.

Mô hình và môi trường của quy trình tham chiếu

Bổ sung để hiểu mã và các kiểm tra

Python — truy vấn SQLite có tham số giải thích sự tách biệt giữa truy vấn và các giá trị. Python — boolean nêu rõ mối liên hệ giữa bool và int, còn Python — hashlib ghi lại cách tính dấu vân tay.

Docker Compose — services mô tả các tùy chọn chạy và mount. llama.cpp — máy chủ trình bày suy luận và đầu ra bị ràng buộc. JSON Schema — enum trình bày chi tiết việc giới hạn một trường vào danh sách giá trị.

Chia sẻ bài viết