Văn Bản Là Phần Dễ: Quy Trình Minh Họa Bài Viết Do AI Tạo Ra

Việc tạo văn bản đã được giải quyết. Minh họa nó thì chưa. Quy trình toàn diện — prompt điều phối, tìm kiếm ảnh, sơ đồ Mermaid, biểu đồ đã xác minh, cùng alt text, ghi công và markup ImageObject biến nó thành SEO.

Chia sẻ
Một lập trình viên đang gõ máy tại bàn làm việc với hai màn hình đầy code, được chiếu sáng bởi đèn màu.
Ảnh từ Unsplash

Bạn là một developer với một mục tiêu về nội dung: vài chục bài mỗi tháng, có thể là hàng trăm. Mô hình viết bài xử lý bản thảo, dàn ý, meta description, các liên kết nội bộ. Rồi pipeline chạm đến bước duy nhất chưa có mô hình riêng — hình ảnh — và dừng lại. Không phải vì ảnh khó kiếm, mà vì không có gì trong hệ thống biết tấm ảnh nào, từ đâu, với giấy phép gì, được miêu tả ra sao.

Bài viết này chính là bước còn thiếu đó, được nối liền từ đầu đến cuối: loại visual nào nên tạo cho loại section nào, prompt biến một bản thảo hoàn chỉnh thành các brief tìm kiếm, lệnh gọi API trả về các tấm ảnh, mô hình thứ hai vẽ ra những gì một tấm ảnh không thể diễn đạt, và bộ lắp ráp xuất ra markup mà Google có thể đọc thật sự. Hai workflow hoàn chỉnh ở cuối bài, sẵn sàng để bạn lấy dùng.

Văn bản đã được giải quyết. Minh họa là nơi mọi thứ dừng lại.

Hãy làm một cuộc kiểm định thẳng thắn với pipeline bài viết tự động. Dàn ý: đã xong. Bản thảo: đã xong. Tiêu đề, meta description, schema, liên kết nội bộ, dịch thuật: đã xong, tất cả do cùng một mô hình xử lý, tất cả bằng văn bản. Sau đó:

Bước trong pipeline Trạng thái Thứ thực sự gây tắc nghẽn
Dàn ý & bản thảo Đã giải quyết Một lệnh gọi mô hình, một prompt
Tiêu đề, meta, schema, liên kết Đã giải quyết Văn bản vào, văn bản ra
Ảnh hero Bị chặn Cần một file thật, một giấy phép, kích thước và alt text — không cái nào mô hình văn bản tự tạo được
Ảnh cho section Bị chặn Ba đến năm tấm mỗi bài, mỗi tấm khác nhau, không lặp lại trên toàn site
Biểu đồ & sơ đồ Bị chặn Phải chính xác — visual duy nhất mà tìm kiếm không trả về được và một bộ sinh không được phép bịa ra

Sự thất bại này không nằm ở mặt thẩm mỹ, mà mang tính cấu trúc: bài viết được xuất bản với một ảnh stock đóng vai placeholder, hoặc với đúng tấm ảnh đã dùng cho mười hai bài trước, hoặc với một ảnh do AI sinh ra mà bàn tay sáu ngón chính là thứ đầu tiên người đọc thấy. Và tấm ảnh không phải là trang trí — chính tài liệu hình ảnh của Google đã nói rõ: alt text “là thuộc tính quan trọng nhất khi cần cung cấp metadata cho một hình ảnh”, và hướng dẫn là dùng những phần tử <img> thật với alt mang tính miêu tả thay vì dùng ảnh nền CSS, để hình ảnh có thể được tìm thấy và hiểu được ngay từ đầu.1

Bốn loại visual, bốn loại mô hình

Sai lầm thiết kế lớn nhất chính là coi “hình ảnh” là một bài toán duy nhất với một nhà cung cấp duy nhất. Đó là bốn bài toán, và bộ định tuyến giữa chúng là một dòng prompt, không phải một dịch vụ:

Section cần… Hãy tạo bằng Vì sao không dùng cách khác
Một cảnh từ thế giới thựchero, tình huống con người, địa điểm, vật thể, cử chỉ Tìm kiếm ảnh theo ngữ nghĩa (Pexafy) Một bộ sinh sẽ tự bịa ra chi tiết; một biểu đồ chẳng có gì để vẽ
Những con số bạn đang có sẵnbenchmark, giá cả, kết quả khảo sát, độ trễ Một mô hình viết code vẽ biểu đồ, chạy trong sandbox Không thể tin tưởng một mô hình sinh ảnh với một con số cụ thể; một tấm ảnh không thể mang dữ liệu
Một cấu trúc hoặc một luồngkiến trúc, trình tự, máy trạng thái Một mô hình viết Mermaid / Graphviz, render một cách xác định Các thư viện ảnh miễn phí không có sơ đồ nào của hệ thống của bạn
Sản phẩm của bạn trên màn hìnhdocs, changelog, hướng dẫn Ảnh chụp màn hình bằng trình duyệt điều khiển bởi script (Playwright) Không gì khác có thể hiển thị một UI chỉ tồn tại trong bản build của bạn

Còn tranh minh họa được tạo bằng AI? Nó vẫn giữ một vị trí chính đáng: cảnh không thể chụp được và không phải là dữ liệu — một cơ chế trừu tượng, một sản phẩm chưa tồn tại, một phong cách minh họa riêng của bạn. (Toàn bộ lập luận về việc dùng ảnh chụp thật thay vì tạo ảnh — tốc độ khi làm số lượng lớn, độ chính xác, vấn đề trùng lặp — được trình bày ở đây.) Xu hướng giá đang đi theo chiều này: từ ngày 2 tháng 8 năm 2026, Điều 50 của Luật AI của EU yêu cầu các nhà cung cấp hệ thống sinh tạo phải đánh dấu đầu ra tổng hợp theo định dạng máy có thể đọc được.2 Đây là nghĩa vụ đặt lên các nhà cung cấp và triển khai AI, không phải quy định về việc blog được phép xuất bản gì — nhưng đó là lý do vì sao nguồn gốc của bức ảnh ở đầu bài viết ngày càng là điều người đọc có thể kiểm chứng, chứ không chỉ tin theo lời nói.

Pipeline, từ đầu đến cuối

Năm giai đoạn. Chỉ giai đoạn 3 đụng tới một API hình ảnh, và chỉ giai đoạn 4 là tùy chọn:

Hình dạng tổng thể — một bài viết vào, một bài viết sẵn sàng xuất bản ra
┌─ 1. WRITE ────────────────────────────────────────────────────┐
   topic ──▶ LLM ──▶ draft.md  (h2 sections, front-matter)
└───────────────────────────────┬───────────────────────────────┘
┌─ 2. BRIEF ────────────────────┴───────────────────────────────┐
   draft.md ──▶ LLM ──▶ { hero: {...}, sections: [ {...} ] }
                     one JSON object: per slot, a kind +
                     either a camera brief or a data/diagram spec
└──────────┬──────────────────────────────────┬─────────────────┘
           │ kind = "photo"                │ kind = "chart" | "diagram"
           ▼                               ▼
┌─ 3. SEARCH ───────────────┐   ┌─ 4. DRAW (optional) ─────────┐
   GET /search/photos          LLM ──▶ mermaid | plotting code
   ← credited photo +           ──▶ sandbox ──▶ .svg / .png
     w/h, blur_hash, alt,       (deterministic render, no
     licence, source URL         invented numbers)
└──────────┬────────────────┘   └──────────────┬───────────────┘
           └───────────────┬──────────────────┘
┌─ 5. ASSEMBLE ─────────────┴───────────────────────────────────┐
   <img> with width/height + fetchpriority | loading
   alt written for a human · visible credit · ImageObject JSON-LD
   photo_id stored so no two pages share a hero
└───────────────────────────────────────────────────────────────┘

Hai đặc điểm quan trọng hơn cả sơ đồ. Giai đoạn 2 là một bộ định tuyến: nó quyết định cho mỗi slot bộ sinh nào sẽ chạy, để bạn không bao giờ phải hỏi một thư viện ảnh để lấy một biểu đồ cột. Và giai đoạn 5 là nơi SEO thực sự nằm ở đó — mọi thứ Google có ghi trong tài liệu về hình ảnh (alt mang tính miêu tả, metadata giấy phép, một ảnh hero an toàn cho LCP) được xuất ra ở đây, từ các trường dữ liệu mà phản hồi tìm kiếm đã sẵn có.

Giai đoạn 2: biến bản thảo thành các brief visual

Một lệnh gọi mô hình duy nhất trên bản thảo hoàn chỉnh, và kết quả trả về là một kế hoạch chứ không phải một truy vấn. Cách viết một brief ảnh đơn — vì sao tiêu đề bài viết là đầu vào tồi nhất có thể, một brief camera 12 đến 25 từ trông như thế nào, và những cách nó có thể thất bại — đã được trình bày đầy đủ trong hướng dẫn minh họa một bài viết, và không được lặp lại ở đây. Điều một pipeline bổ sung thêm là định tuyến: cùng một lệnh gọi phải quyết định, theo từng vị trí, bộ tạo nào sẽ chạy — và một mục biểu đồ mang dữ liệu trong khi một mục ảnh mang một cảnh.

Prompt định tuyến — sao chép nguyên văn
# system prompt — chạy một lần cho mỗi bản thảo đã hoàn thành
Bạn là giám đốc hình ảnh của một ấn phẩm kỹ thuật. Đọc bài viết và
trả về kế hoạch visual: một mục cho hero, một mục cho mỗi section H2.

Với mỗi mục, chọn đúng một kind:
  "photo"    một cảnh thực sự: ai đó đang làm gì ở đâu đó, một địa điểm,
              một vật thể, một cử chỉ. Mặc định cho hero.
  "chart"    section nêu ra những số liệu CÓ trong bài viết. Không bao giờ
              bịa ra giá trị: sao chép chúng vào data, nguyên văn.
  "diagram"  section mô tả một cấu trúc, một luồng hoặc một trình tự.
  "none"     section ngắn, hoặc đã có sẵn một code block.

Quy tắc cho mục "photo" — trường dữ liệu là query:
Viết một mô tả cảnh cho máy ảnh: một khung cảnh mà máy ảnh có thể chụp được, từ 12 đến 25 từ,
   bằng tiếng Anh, phù hợp với tâm trạng của phần nội dung. Nêu rõ những gì có trong khung hình,
   không bao giờ nêu chủ đề. Không chữ, logo, thương hiệu hay người nổi tiếng; không ẩn dụ
   vô hình. (Quy tắc đầy đủ, kèm ví dụ và các trường hợp thất bại:
   pexafy.com/blog/illustrate-blog-articles-at-scale/)

KHÔNG viết alt text cho mục photo: tấm ảnh bạn nhận lại là ảnh
khớp gần nhất với brief, không phải cảnh bạn đã mô tả, nên alt text của nó phải
được viết dựa trên tấm ảnh đã chọn. Mục chart và diagram MỚI có alt —
ở đó bạn kiểm soát chính xác thứ được render.

Chỉ trả về JSON:
{
  "hero": { "kind": "photo", "query": "…", "orientation": "landscape" },
  "sections": [
    { "h2": "…", "kind": "photo",   "query": "…" },
    { "h2": "…", "kind": "chart",   "title": "…", "unit": "ms",
      "data": [ {"label": "…", "value": 0} ], "alt": "…" },
    { "h2": "…", "kind": "diagram", "spec": "flowchart LR; …", "alt": "…" }
  ]
}

Dòng kind làm phần lớn công việc, và chỉ dẫn data làm phần còn lại: một mục biểu đồ chỉ được mang những số liệu đã xuất hiện trong bản thảo, để mô hình chép lại chứ không bịa ra. Đây là bộ định tuyến trên ba mục thực tế của một bài viết dành cho lập trình viên:

Section kind Bộ định tuyến trả về
Hero — “Vì sao bản build hằng đêm của chúng tôi mất 40 phút” photo “a developer working late at a desk with two monitors and a mechanical keyboard in a dark room lit by the screens”
“Thời gian thực sự chảy đi đâu” chart data sao chép từ đoạn văn: install 480 s, compile 1080 s, test 720 s, upload 120 s
“Chúng tôi chia đồ thị công việc thế nào” diagram flowchart LR của đồ thị phụ thuộc giữa các job
“Chúng tôi đã thay đổi gì, và sẽ làm lại điều gì” photo “two engineers standing at a whiteboard covered in diagrams working through a problem together”

Giai đoạn 3: những tấm ảnh trở về kèm ghi công

Mỗi mục kind: "photo" là một request. Brief cho ảnh hero ở trên, chạy trên API công khai, trả về kết quả này trong 147 ms:

GET /search/photos — brief hero · “a developer working late at a desk with two monitors…” · 147 ms
Engine xếp hạng theo ý nghĩa, nên câu tìm kiếm dài sẽ thu hẹp tập kết quả thay vì làm nó trống rỗng. Chạy chính xác lượt tìm kiếm này →

Brief cho section cuối, một cảnh hoàn toàn khác, trong 144 ms:

GET /search/photos — brief section · “two engineers standing at a whiteboard covered in diagrams…” · 144 ms
Cùng một bài viết, cùng một lượt chạy, một cảnh không ai nhầm lẫn với ảnh hero — vì brief được viết theo từng section, không theo cả bài viết. Chạy thử lượt tìm kiếm này luôn →

Những gì trả về cho mỗi tấm ảnh là phần khiến giai đoạn 5 khả thi — không chỉ là một file:

Một kết quả, đã lược bớt chỉ giữ các trường mà bộ lắp ráp sử dụng
{
  "photo_id":  "019e1c7f-0063-759e-b498-33ce1714e6c9",   // lưu lại: không lặp
  "urls": { "small": "…?w=400", "regular": "…?w=1080",
             "large": "…?w=1920" },
  "width": 3000, "height": 1688,          // → không có layout shift
  "blur_hash": "LJ8gjv9rVq-6OFxanNNFI7xco$Na",   // → placeholder thật
  "alt_description": "Person types on keyboard in front of dual monitors…",
  "photographer_full_name": "Jakub Żerdzicki",   // → ImageObject.creator
  "source": "Unsplash", "license_type": "free",
  "source_image_url": "https://unsplash.com/photos/…",
  "attribution": { "html": "<span…>Photo by …</span>",
                    "plain": "Photo by Jakub Żerdzicki on Unsplash (…)" }
}

Giai đoạn 4: những gì một tấm ảnh không thể diễn đạt

Hai slot trong kế hoạch không thể tìm kiếm được, và đây chính là chỗ mô hình thứ hai chứng minh giá trị của nó — không phải để vẽ một bức tranh, mà để viết code vẽ ra nó. Sự khác biệt này quan trọng: code có thể review, mang tính xác định, và không thể tự bịa ra một chiều cao cột biểu đồ.

Sơ đồ: văn bản vào, SVG ra

Mermaid render sơ đồ từ một định nghĩa dạng văn bản thuần,3 điều này khiến nó trở thành đích ngắm an toàn nhất cho một mô hình: đầu ra có thể kiểm tra, có thể diff trong git, và render theo cùng một cách mỗi lần. Bộ định tuyến đã trả về spec sẵn.

diagram.sh — mô hình viết spec, CLI render nó
# trường "spec" của một mục diagram, ghi vào build/graph.mmd
cat build/graph.mmd
flowchart LR
  install["install deps · 480s"] --> compile["compile · 1080s"]
  compile --> test["test suite · 720s"]
  compile --> upload["upload artifacts · 120s"]

npx -y @mermaid-js/mermaid-cli -i build/graph.mmd -o static/img/graph.svg
# → một SVG bạn có thể review trong PR, không phải một bức tranh phải tin tưởng suông

Biểu đồ: chỉ những con số bài viết đã sẵn có

Cùng nguyên tắc, thêm một chốt bảo vệ. Bộ định tuyến sao chép các giá trị ra từ bản thảo; mô hình viết code vẽ biểu đồ; code chạy trong sandbox; bộ lắp ráp kiểm tra lại các giá trị đã render so với số liệu nguồn trước khi biểu đồ được cho phép xuất hiện gần trang bài viết.

chart.py — vẽ dữ liệu đã sao chép, rồi kiểm tra lại
import re, matplotlib
matplotlib.use("Agg")                # headless: không có display trong CI
import matplotlib.pyplot as plt

def assert_in_draft(value, draft: str) -> None:
    """Một con số được vẽ ra phải xuất hiện trong bài viết DƯỚI DẠNG SỐ."""
    # Khớp chuỗi con là cái bẫy ở đây: "120" nằm trong "1200", và
    # cũng nằm trong "?w=1200" — một `str(v) in draft` ngây thơ sẽ pass với mọi thứ.
    # Khớp theo ranh giới từ, và chấp nhận cả 1 234 / 1,234 / 1234.
    body = re.sub(r"(?<=\d)[  ,](?=\d{3}\b)", "", draft)   # bỏ dấu phân cách
    if not re.search(rf"(?<![\d.]){re.escape(str(value))}(?![\d.])", body):
        raise ValueError(f"{value} is not stated in the article — refusing to plot")

def render_chart(entry: dict, draft: str, out: str) -> str:
    labels = [d["label"] for d in entry["data"]]
    values = [d["value"] for d in entry["data"]]
    for v in values:
        assert_in_draft(v, draft)       # giá trị bịa ra → không có biểu đồ

    fig, ax = plt.subplots(figsize=(8, 4.5), dpi=160)
    ax.barh(labels, values)
    ax.set_xlabel(entry["unit"])
    ax.set_title(entry["title"])
    fig.tight_layout()
    fig.savefig(out)                    # artefact xác định, có thể review
    return out

Chốt bảo vệ này ngắn, nhưng hãy viết nó cẩn thận: kiểm tra chuỗi con không hiệu quả. "120" in draft đúng với một bài viết chứa 1200 hoặc ?w=1200, vậy nên phiên bản ngây thơ pass với mọi thứ và không bảo vệ được gì cả. Hãy neo vào ranh giới ký số, chuẩn hóa dấu phân cách nghìn, và loại lỗi khiến một bài viết kỹ thuật bị vạch trần trong phần comment — một biểu đồ mâu thuẫn với chính đoạn văn của nó — sẽ không thể lọt vào production. Ảnh chụp màn hình theo cùng nguyên tắc: một page.screenshot() được script hóa chụp từ bản build thật của bạn là nguồn sự thật duy nhất cho UI của chính bạn, và nó luôn đúng khi UI thay đổi.

Giai đoạn 5: lắp ráp là nơi SEO thực sự nằm ở đó

Mọi thứ đến nay đều tạo ra file và các trường dữ liệu. Giai đoạn này biến chúng thành markup — và đây là chỗ đáng để chính xác, vì ba hành vi có ghi trong tài liệu chính thức được quyết định trong vài dòng này.

Ảnh hero, xuất ra từ phản hồi tìm kiếm — không có gì bịa đặt
<!-- Các byte đến từ một origin khác: trả giá handshake sớm -->
<link rel="preconnect" href="https://images.unsplash.com" crossorigin>

<!-- Phần tử LCP: không bao giờ lazy, luôn ưu tiên cao -->
<figure>
  <img src="{urls.regular}"
       width="{width}" height="{height}"        <!-- diệt layout shift -->
       alt="{alt}"                                <!-- viết SAU khi đã chọn ảnh -->
       fetchpriority="high" decoding="async"
       style="background:{color_hex}">   <!-- màu chủ đạo, 1 trường -->
  <figcaption>{attribution.html}</figcaption>
</figure>

<!-- Ảnh section, dưới màn hình đầu tiên: cài đặt ngược lại -->
<img src="{urls.regular}" width="{width}" height="{height}"
     alt="{alt}" loading="lazy" decoding="async">

Hotlink hay tự lưu trữ? Đoạn snippet trên hotlink, đây là cách nhanh nhất để xuất bản và là lý do có preconnect: một ảnh hero ở xa tốn một lượt tra DNS và một handshake TLS trên critical path, và điều đó có thể ăn hết phần lợi ích bạn vừa mua được bằng fetchpriority. Tự lưu trữ loại bỏ hoàn toàn origin bên thứ ba, cho phép bạn phục vụ AVIF/WebP theo các breakpoint riêng của mình, và sống sót qua việc URL nguồn thay đổi — với cái giá là lưu trữ, một bước fetch trong pipeline và hóa đơn CDN riêng của bạn. Bất kể bạn chọn cách nào, color_hex cho bạn một placeholder cho một trường (blur hash đẹp hơn, nhưng nó phải được decode thành data URI trước — nó không phải là một màu CSS). Sao chép một ảnh hero thô thẳng vào background: chính là chỗ mà hầu hết các pipeline vô tình xuất bản một hộp xám trống rỗng.

  1. Không bao giờ lazy-load ảnh hero. web.dev nói rõ ràng không mập mờ: “Không bao giờ lazy-load ảnh LCP của bạn, vì điều đó sẽ luôn dẫn đến việc trì hoãn tải tài nguyên không cần thiết, và gây tác động tiêu cực đến LCP”, và khuyến nghị dùng fetchpriority="high" trên phần tử có khả năng là LCP — sử dụng dè dặt, chỉ trên một ảnh.4 Một pipeline đóng dấu loading="lazy" lên mọi hình ảnh, kể cả hero, là vết thương Core Web Vitals tự gây phổ biến nhất trong xuất bản tự động.
  2. Luôn xuất width và height — chúng đã có sẵn trong phản hồi, nên không có lý do gì để bỏ qua; đúng cặp thuộc tính đó cho phép trình duyệt dành trước khoảng không gian và ngăn layout nhảy loạn. Dùng blur_hash làm placeholder trong khi file đang tải.
  3. Viết alt sau khi đã chọn ảnh, không bao giờ trước. Đây là chi tiết tinh tế nhất. Trường alt trong kế hoạch mô tả cảnh bạn đã yêu cầu; tấm ảnh bạn nhận được chỉ là kết quả khớp gần nhất, không phải cảnh đó. Xuất bản luôn văn bản của brief làm alt chính là lỗi accessibility mà pipeline này đáng lẽ phải tránh — một mô tả về một tấm ảnh không có trên trang. Hãy xây alt từ alt_description của tấm ảnh đã chọn, tinh chỉnh lại theo đoạn văn nó nằm trong đó. Hướng dẫn của Google là “tập trung tạo nội dung có ích, giàu thông tin, sử dụng từ khóa một cách phù hợp và trong ngữ cảnh của nội dung trang”, và cảnh báo rằng nhồi nhét từ khóa vào thuộc tính alt “dẫn đến trải nghiệm người dùng tiêu cực và có thể khiến trang của bạn bị xem như spam”.1

Phần hầu như không ai tự động hóa: metadata giấy phép

Google hỗ trợ dữ liệu có cấu trúc ImageObject cho việc cấp phép hình ảnh. Nó yêu cầu contentUrl cộng thêm ít nhất một trong số creator, creditText, copyrightNotice hoặc license, khuyến nghị acquireLicensePage, và những hình ảnh có thông tin giấy phép sẽ trở nên đủ điều kiện nhận huy hiệu Licensable trong Google Images.5 Mỗi trường dữ liệu đó đã có sẵn trong phản hồi tìm kiếm — nên việc xuất ra nó là một template, không phải một dự án:

ImageObject JSON-LD, điền từ phản hồi API
<script type="application/ld+json">
{
  "@context": "https://schema.org/",
  "@type": "ImageObject",
  "contentUrl": "{urls.large}",                 // bắt buộc
  "creator": { "@type": "Person",
                "name": "{photographer_full_name}" },
  "creditText": "{photographer_full_name} on {source}",
  "license": "{LICENSE_URL[source]}",           // trang giấy phép của chính thư viện đó
  "acquireLicensePage": "{source_image_url}"     // trang của tấm ảnh
}
</script>

# LICENSE_URL ánh xạ trường `source` sang giấy phép thực sự chi phối
# tấm ảnh đó — unsplash.com/license, pexels.com/license, pixabay.com/…

Một chi tiết đáng làm cho đúng: license nên chỉ đến giấy phép chi phối chính tấm ảnh đó — trang giấy phép riêng của thư viện nguồn — không phải một trang tóm tắt trên domain của bạn. Google đọc trường này để quyết định điều kiện huy hiệu, và một URL tự tham chiếu vừa yếu hơn về tín hiệu vừa khó bảo vệ như thứ gì khác ngoài một liên kết trỏ về chính mình. Giữ trang tóm tắt giấy phép riêng của bạn như một trang nội bộ cho người đọc; đặt giấy phép chính thức trong markup.

Và trong khi đang ở phần lắp ráp: hãy đặt tên file ngắn và mang tính miêu tả thay vì IMG_0042.jpg, và thêm hình ảnh vào sitemap — định dạng image sitemap của Google chấp nhận tối đa 1.000 hình ảnh cho mỗi URL trang.6 Cả hai việc chỉ tốn một dòng trong pipeline và không bao giờ được làm bằng tay.

Hai pipeline để bạn lấy dùng

Cùng năm giai đoạn, hai hình dạng rất khác nhau — một cho các bài viết bạn viết, một cho tài liệu kỹ thuật phải khớp với sản phẩm đang chạy thực tế. Chọn cái mà bạn nhận ra kiểu lỗi của nó.

1 · Blog dev trong CI — Markdown trong repo

Bài viết sống dưới dạng Markdown, hình ảnh được commit ngay cạnh chúng, và toàn bộ chạy khi push. Có tính xác định, có thể review trong PR, không phụ thuộc runtime vào bất kỳ API nào:

.github/workflows/illustrate.yml
on: { pull_request: { paths: ["content/**.md"] } }

jobs:
  illustrate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: pip install -r requirements.txt
      - run: python plan_to_pr.py $(git diff --name-only origin/main -- 'content/*.md')
        env:
          PEXAFY_API_KEY: ${{ secrets.PEXAFY_API_KEY }}
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
      - uses: peter-evans/create-pull-request@v6   # hình ảnh xuất hiện trong PR
        with: { commit-message: "chore(content): illustrate" }

Một con người vẫn phải duyệt PR, đó chính là điều quan trọng: pipeline đề xuất, người review quyết định, và front-matter mà nó viết ra có thể diff được.

Phần tìm kiếm chỉ là một GET — yêu cầu, score_threshold của nó và các trường mà nó trả về đã được viết ra từng dòng trong hướng dẫn cho một bài viết đơn, nên ở đây nó chỉ được nhập vào chứ không lặp lại. Điều tệp này bổ sung là mọi thứ mà một kế hoạch cần và một tấm ảnh đơn lẻ thì không: việc thử lại khi một brief không trả về kết quả nào, việc đánh dấu một photo_id để không có hai trang nào dùng chung một tấm ảnh, và phần alt được viết từ tấm ảnh vừa trả về.

plan_to_pr.py — phần kết nối: kế hoạch hình ảnh vào, front-matter ra
import frontmatter
from photo_search import search   # một GET /search/photos; bỏ qua các id trong `used`

def find_photo(entry: dict, used: set) -> dict | None:
    """Tìm kiếm; nếu brief quá cụ thể, mở rộng nó một lần, rồi bỏ cuộc."""
    orientation = entry.get("orientation", "landscape")
    for query in (entry["query"], widen(entry["query"])):
        photo = search(query, orientation, used)
        if photo:
            used.add(photo["photo_id"])   # đánh dấu đã dùng: không lặp trên toàn site
            return photo
    return None                        # người gọi quyết định: bỏ slot, hoặc fail

def widen(query: str) -> str:
    """Bỏ mệnh đề cuối cùng — thường là mệnh đề quá cụ thể."""
    return query.rsplit(" in ", 1)[0] if " in " in query else query

def illustrate(path: str, plan: dict, used: set) -> None:
    post = frontmatter.load(path)
    hero = find_photo(plan["hero"], used)
    if hero is None:                    # không có hero còn hơn một hero tệ
        raise SystemExit(f"{path}: no photo above threshold — rewrite the brief")

    post["hero"] = {                    # mọi thứ template cần
        "src": hero["urls"]["regular"], "w": hero["width"], "h": hero["height"],
        # Alt mô tả tấm ảnh chúng ta ĐÃ NHẬN, không bao giờ là cảnh đã yêu cầu.
        "alt": alt_for(hero, section=post.get("title", "")),
        "bg": hero["color_hex"], "credit": hero["attribution"]["html"],
        "creator": hero["photographer_full_name"], "id": hero["photo_id"],
        "source": hero["source"],
        "source_url": hero["source_image_url"],   # → acquireLicensePage
    }
    open(path, "w").write(frontmatter.dumps(post))

def alt_for(photo: dict, section: str) -> str:
    """alt_description làm nền, cắt về khoảng ~125 ký tự cho screen reader.
    Gửi lại qua mô hình cùng với đoạn văn nếu bạn muốn kết quả tốt hơn."""
    base = photo.get("alt_description") or photo.get("description", "")
    return base[:125].rstrip(" ,;")

2 · Docs & changelog — ảnh chụp màn hình trước, ảnh chụp thực tế sau

Đảo ngược các mặc định của bộ định tuyến. Trong tài liệu sản phẩm, visual trung thực nhất hầu như luôn là UI của chính bạn: một script Playwright mở bản build thật, đặt một viewport cố định và chụp đúng trạng thái mà đoạn văn mô tả. Sơ đồ dùng cho các trang kiến trúc, và ảnh chụp thực tế chỉ xuất hiện trên các trang khái niệm và trang landing — nơi một ảnh chụp màn hình sẽ chẳng nói lên được gì.

shots.py — ảnh chụp màn hình được tạo ra, không bao giờ được miêu tả
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch()
    page = browser.new_page(viewport={"width": 1440, "height": 900},
                            device_scale_factor=2)   # nét sắc như retina
    page.goto("http://localhost:3000/dashboard")
    page.get_by_role("button", name="New API key").click()
    page.screenshot(path="static/img/docs/new-api-key.png")
    browser.close()
# Chạy trong cùng job CI với bản build docs → ảnh chụp màn hình không bao giờ có thể
# mô tả một phiên bản UI không còn tồn tại nữa.

Có hai biến thể đáng nêu tên nhưng chưa đáng để có công thức riêng. SEO lập trình đảo ngược vòng lặp: với hàng trăm trang được sinh ra từ một cơ sở dữ liệu, bạn không tìm kiếm cho mỗi trang — một request trả về tối đa 100 ảnh, nên bạn tìm kiếm theo chùm chủ đề và phân phối từ một pool, với một điều kiện tính duy nhất đảm nhiệm việc chống lặp (kiến trúc đó, chi tiết đầy đủ). Và newsletter và social card cần đúng một tấm ảnh trong bốn tỷ lệ cắt: giữ lại photo_id, yêu cầu kích thước bạn cần từ urls, và chỉ tìm kiếm lại khi một lượt cắt thực sự thất bại.

Cùng một pipeline, dưới hình dạng một agent

Nếu một model đã đang viết bản nháp, con đường ngắn nhất là đưa công cụ tìm kiếm cho nó trực tiếp thay vì chuyển JSON qua lại giữa các tiến trình. Pexafy vận hành một máy chủ Model Context Protocol được lưu trữ tại mcp.pexafy.com/mcp. Việc thiết lập connector và các công cụ nó cung cấp đã được nói tới ở nơi khác — thiết lập desktop và editor trong hướng dẫn cho một bài viết đơn lẻ, biến thể headless cho một CI runner trong bài viết về nội dung ở quy mô lớn, và bức tranh rộng hơn của thị trường connector — ai cung cấp máy chủ hình ảnh MCP, theo những điều khoản nào — trong nghiên cứu về hạ tầng tìm kiếm hình ảnh cho các AI agent. Điều đáng chỉ ra ở đây là điều gì xảy ra với pipeline này khi agent nắm trong tay các công cụ: nó không còn là năm giai đoạn nữa mà trở thành một chỉ dẫn duy nhất.

Một chỉ thị, toàn bộ kế hoạch được thực thi
You  Đây là bản thảo. Hãy xây kế hoạch visual, minh họa nó, và mở một PR.
     Biểu đồ chỉ dùng số liệu đã có sẵn trong văn bản.

Agent  → plan: hero=photo · §2=chart · §3=diagram · §4=photo
      → search_photos(q="a developer working late at a desk with two
         monitors and a mechanical keyboard in a dark room…")
      ← 16 photos · 147 ms · picked #1, 3000×1688, credited
      → mermaid-cli build/graph.mmd → static/img/graph.svg
      → chart.py §2 → 4 values checked against the draft ✓
      → search_photos(q="two engineers standing at a whiteboard…")
      ← 16 photos · 144 ms · picked #1

      ✓ 4 slot đã điền · 2 lệnh gọi API · alt + credit + ImageObject đã viết
      ⚠ §5 không trả về gì trên ngưỡng 0.5 — brief quá trừu tượng, được viết lại thành
        "a person at a kitchen table checking figures on a laptop"

Dòng cuối đó chính là lý do một agent đáng có trong vòng lặp này hơn là một script thuần túy: kiểu lỗi của minh họa tự động là một brief tệ, và viết lại một brief tệ chính là việc mà một mô hình ngôn ngữ sinh ra để làm. Hãy giữ các phần mang tính xác định — phân phối, chống lặp, chốt kiểm tra số liệu — trong code.

Chi phí cho một bài viết được minh họa

Ba slot ảnh mỗi bài nghĩa là ba request tìm kiếm, nên plan Free (5,000 requests/month) đủ dùng cho 1,666 bài viết mỗi tháng trước khi câu hỏi về trả phí đặt ra — và nếu bạn pool các lượt tìm kiếm theo chùm chủ đề thay vì theo từng bài, ngưỡng đó dịch đi thêm một bậc độ lớn nữa. Bảng phân tích chi tiết theo từng plan, và kiến trúc pooling giúp câu hỏi này trở nên không còn quan trọng, có trong bài viết đồng hành về minh họa nội dung ở quy mô lớn.

Hai lệnh gọi mô hình cho mỗi bài viết — một cho bản thảo, một cho kế hoạch visual — chỉ tốn vài nghìn token và sẽ là dòng chi phí rẻ nhất trong pipeline; các bản render Mermaid và matplotlib không tốn gì ngoài vài giây CI. Điều khiến người ta bất ngờ là dòng nào không rẻ: sinh bốn ảnh mỗi bài viết, bốn trăm ảnh mỗi tháng, cộng thêm các lần thử không đạt yêu cầu — và kết quả cuối cùng vẫn không có tên nhiếp ảnh gia, không ngày tháng, không URL nguồn.

Điều pipeline này không khắc phục được

Một section thẳng thắn, vì cái sai lầm nó ngăn chặn là đắt đỏ. Một bài viết được minh họa tốt vẫn chỉ là một bài viết: ảnh thật, biểu đồ chính xác và markup đúng chuẩn giúp cải thiện một trang đáng được tồn tại. Chúng không khiến nội dung mỏng, sản xuất đại trà lên hạng được. Chính sách spam của Google gọi tên lạm dụng nội dung ở quy mô lớn — tạo ra nhiều trang chủ yếu để thao túng thứ hạng và mang lại ít giá trị cho người dùng, bất kể có dùng tự động hóa hay không — và chất lượng minh họa không phải là yếu tố trong đánh giá đó.7

Vậy khuôn khổ đứng vững là: pipeline này là một sàn chất lượng bạn kiểm soát, áp dụng cho những trang đã có lý do để được xuất bản. Nơi nó thực sự có giá trị:

  • Nguồn gốc mà người đọc kiểm tra được. Một dòng ghi công với tên nhiếp ảnh gia thật và một URL nguồn là một tuyên bố có thể kiểm tra được — và đúng những trường dữ liệu đó nạp vào markup ImageObject mà Google đọc.
  • Accessibility và Core Web Vitals. Alt text thật, kích thước trên mọi hình ảnh, một ảnh hero không bao giờ bị lazy-load. Nhân điều này cho mọi bài viết bạn xuất bản và đây chính là câu chuyện về chất lượng hình ảnh của cả trang web.
  • Chính xác ở những chỗ có thể kiểm chứng. Một biểu đồ có số liệu được xác nhận khớp với bài viết, một ảnh chụp màn hình sinh ra từ bản build đang chạy thật, một sơ đồ có thể review dưới dạng văn bản trong PR — ba loại visual không thể lệch khỏi sự thật mà không làm test fail.

Về việc ghi công, điểm đặc thù cho pipeline khá hẹp: lý do vì sao nên luôn hiển thị credit đã được trình bày ở nơi khác, và điều một bộ lắp ghép tự động bổ sung là cùng một chuỗi attribution mà nó in ra chính là creditText mà dữ liệu có cấu trúc cần. Một trường, hai nơi, được tạo ra trong cùng một lượt render template — đó là lý do vì sao một pipeline lại càng ít có lý do để bỏ qua nó hơn cả con người.

Bắt đầu từ đâu

  1. Thêm prompt định tuyến vào bất cứ thứ gì đang viết bản thảo của bạn, và in ra JSON mà không hành động theo nó. Đọc mười kế hoạch. Nếu các brief nêu tên chủ đề thay vì cảnh, sửa prompt trước khi viết bất kỳ tích hợp nào.
  2. Nối dây chỉ cho các slot ảnh. Một GET /search/photos cho mỗi brief, và lưu photo_id từ ngày đầu tiên — chống lặp được vá lại sau trên 3.000 trang đang chạy là một cuộc di trú, không phải một cột dữ liệu.
  3. Xuất width, height và alt trong cùng một commit. Đây là công việc Core Web Vitals rẻ nhất bạn từng làm.
  4. Rồi thêm giai đoạn 4, sơ đồ trước biểu đồ — Mermaid là văn bản, nên đây là loại không có kiểu lỗi nào ngoài lỗi cú pháp.
  5. Thêm chốt kiểm tra số liệu trước khi biểu đồ đầu tiên chạm tới người đọc, không phải sau đó.

Tài liệu tham khảo & chú thích

1 Google Search Central, Image SEO best practices: alt text là “thuộc tính quan trọng nhất khi cần cung cấp metadata cho một hình ảnh”; hướng dẫn là tạo ra “nội dung có ích, giàu thông tin, sử dụng từ khóa một cách phù hợp và trong ngữ cảnh của nội dung trang”, tránh nhồi nhét từ khóa vào thuộc tính alt, sử dụng phần tử HTML <img> thay vì ảnh nền CSS, và đặt tên file ngắn nhưng mang tính miêu tả.

2 EU AI Act, Điều 50 — nghĩa vụ minh bạch áp dụng từ ngày 2 tháng 8 năm 2026: các nhà cung cấp hệ thống sinh ảnh, âm thanh, video hoặc văn bản tổng hợp phải đánh dấu đầu ra theo một định dạng máy có thể đọc được và làm cho chúng có thể phát hiện được là do AI sinh ra. Nó ràng buộc các nhà cung cấp và triển khai AI; đây không phải là quy định về việc website nào được phép xuất bản hình ảnh nào.

3 Mermaid render sơ đồ và biểu đồ từ các định nghĩa dạng văn bản lấy cảm hứng từ Markdown, đây là điều khiến đầu ra có thể review và mang tính xác định.

4 web.dev, Optimize Largest Contentful Paint: “Đó là một ý tưởng tốt để đặt fetchpriority="high" trên một phần tử <img> nếu bạn nghĩ nó có khả năng là phần tử LCP của trang”, sử dụng dè dặt; và “Không bao giờ lazy-load ảnh LCP của bạn, vì điều đó sẽ luôn dẫn đến việc trì hoãn tải tài nguyên không cần thiết, và gây tác động tiêu cực đến LCP.”

5 Google Search Central, Image metadata (structured data): ImageObject yêu cầu contentUrl cộng thêm ít nhất một trong số creator, creditText, copyrightNotice hoặc license; acquireLicensePage được khuyến nghị, và những hình ảnh mang thông tin giấy phép có thể trở nên đủ điều kiện nhận huy hiệu Licensable trong Google Images.

6 Google Search Central, Image sitemaps: image sitemap thông báo cho Google về các hình ảnh trên một site, bao gồm cả những ảnh được tìm thấy qua JavaScript, và chấp nhận tối đa 1.000 hình ảnh cho mỗi URL trang.

7 Chính sách spam của Google Search — lạm dụng nội dung ở quy mô lớn: tạo ra nhiều trang chủ yếu để thao túng thứ hạng và mang lại ít giá trị cho người dùng, bất kể được tạo ra bằng tự động hóa, công sức con người hay kết hợp cả hai.

Câu hỏi thường gặp

Làm thế nào để tự động minh họa các bài viết do LLM viết?
Thêm một lệnh gọi mô hình giữa bước viết và xuất bản: yêu cầu nó trả về một kế hoạch hình ảnh — mỗi mục ứng với một vị trí hình ảnh, được gắn nhãn là ảnh, biểu đồ, sơ đồ hoặc không có gì. Các mục ảnh mang theo một mô tả từ 12 đến 25 từ về một cảnh mà máy ảnh có thể chụp được, mà bạn gửi đến một API tìm kiếm ảnh theo ngữ nghĩa (GET /api/v1/search/photos, khoảng 150 ms). Các mục biểu đồ và sơ đồ được chuyển đến một mô hình viết mã vẽ biểu đồ hoặc Mermaid, được render theo cách xác định. Không bao giờ đưa tiêu đề bài viết vào tìm kiếm ảnh: tiêu đề mang tính trừu tượng và không có bức ảnh nào mô tả được chúng.
Trang tài liệu nên dùng ảnh chụp màn hình hay ảnh stock?
Đảo ngược mặc định của router: trong tài liệu sản phẩm, hình ảnh trung thực gần như luôn là giao diện người dùng của chính bạn. Một script Playwright mở bản build thực tế, cố định viewport và chụp lại đúng trạng thái mà đoạn văn mô tả sẽ chạy trong cùng một CI job với việc build tài liệu, vì vậy ảnh chụp màn hình không bao giờ có thể hiển thị một phiên bản giao diện đã không còn tồn tại. Sơ đồ đảm nhiệm các trang kiến trúc, còn ảnh chụp chỉ xuất hiện trên các trang khái niệm và trang landing, nơi mà ảnh chụp màn hình sẽ chẳng nói lên điều gì.
Làm thế nào để ngăn một quy trình AI đưa số liệu sai vào biểu đồ?
Hãy làm cho kế hoạch sao chép thay vì tự bịa ra: bộ điều phối chỉ được phép sao chép các giá trị đã xuất hiện trong bản nháp vào trường data của mục biểu đồ. Sau đó kiểm chứng điều này bằng mã trước khi render — với mỗi giá trị, kiểm tra xem chuỗi của nó có xuất hiện trong bài viết hay không và báo lỗi nếu không. Chín dòng code, và một biểu đồ mâu thuẫn với chính đoạn văn của nó sẽ không bao giờ đến được tay người đọc.
Quy trình tự động nên tạo ra markup hình ảnh nào cho SEO?
Ba thứ, tất cả đều lấy từ các trường mà phản hồi tìm kiếm đã có sẵn. Một alt mô tả được viết trong bối cảnh của đoạn văn — Google gọi alt text là siêu dữ liệu hình ảnh quan trọng nhất và cảnh báo không nên nhồi nhét từ khóa. width và height trên mọi hình ảnh, với fetchpriority="high" trên ảnh chủ đạo và không bao giờ dùng loading="lazy" cho nó, vì hình ảnh LCP không được phép tải lười. Và dữ liệu có cấu trúc ImageObject với contentUrl, creator, creditText và license, chính là điều giúp một hình ảnh đủ điều kiện nhận huy hiệu Licensable trong Google Images.
Việc minh họa các bài viết do AI viết có giúp chúng xếp hạng cao hơn không?
Không phải tự bản thân nó, và cần phải nói rõ. Chính sách chống spam của Google định nghĩa lạm dụng nội dung quy mô lớn là việc tạo ra nhiều trang chủ yếu để thao túng thứ hạng mà mang lại rất ít giá trị cho người dùng, dù có sử dụng tự động hóa hay không; việc minh họa không làm thay đổi đánh giá đó. Điều mà một quy trình tốt mang lại là một mức sàn chất lượng cho những trang vốn đã xứng đáng tồn tại: nguồn gốc có thể xác minh, alt text có thể truy cập, Core Web Vitals sống sót qua quá trình tự động hóa, và các biểu đồ, ảnh chụp màn hình không thể sai lệch khỏi sự thật.
Làm thế nào để chạy bước minh họa trong CI mà không mất quyền kiểm soát biên tập?
Kích hoạt job khi có pull request chạm vào các tệp nội dung của bạn, để nó viết hình ảnh và phần front-matter, rồi cho nó mở một pull request thay vì commit thẳng vào nhánh — pipeline đề xuất, con người phê duyệt, và mọi trường nó viết ra đều có thể diff được. Giữ ba thứ mang tính xác định trong code thay vì trong mô hình: việc loại trùng theo photo_id, khẳng định rằng mọi con số được vẽ biểu đồ đều xuất hiện trong bài viết, và việc thất bại cứng khi không có ảnh nào vượt qua ngưỡng điểm. Không có ảnh hero còn tốt hơn là có một ảnh sai.

Đừng săn lùng từ khóa nữa. Hãy mô tả điều bạn muốn nói.

Tìm kiếm 9M+ ảnh miễn phí sử dụng được theo ý nghĩa — trong mọi ngôn ngữ, dưới 100 ms.