Build a local LLM agent inside product

研究透過 LangChain 替產品導入本地模型與配套 Harness

前言

這個時代開發產品絕對會被反覆要求「我們的產品能不能整合 AI?」、「AI 能帶來什麼新的商機?」,很多團隊包括我也是第一次遇到需要整合 AI 到實際產品的情況。

一個 AI 功能背後需要考慮到成本、可行性等多方面因素,而我在開發的產品性質又是需要斷網的嚴苛環境,因此這篇文章主要研究盡可能基於瀏覽器或本地 LLM 的 MVP,著重探討技術上的可能性。

聽說瀏覽器有 LLM?

很早就聽說過 Chrome 將 LLM 內建於 Browser API 當中,像是 Prompt API🔗但它的普及率🔗可以說是只有最新版的 Chrome 才能試用,API 看起來很像在哪看過:

// 1. 檢查瀏覽器是否支援,並確認模型已下載完畢
const capabilities = await ai.languageModel.capabilities();
if (capabilities.available === 'readily') {
// 2. 建立 session,並在建立時帶入 system prompt
const session = await ai.languageModel.create({
systemPrompt: "You are a helpful and friendly assistant."
});
// 3. 模擬對話歷史 (Prompt API 會在同一個 session 中自動維持 context)
// 第一輪對話 (User)
const response1 = await session.prompt("Can you write a short poem about coding?");
console.log("Assistant:", response1);
// 輸出: Code is the poetry of machines, Where logic weaves through screens and seams.
// 4. 如果你想在建立 session 時就直接注入「過去的對話歷史」(歷史紀錄 payload)
// 可以使用 initialPrompts 參數:
const sessionWithHistory = await ai.languageModel.create({
systemPrompt: "You are a helpful and friendly assistant.",
initialPrompts: [
{ role: 'user', content: 'Can you write a short poem about coding?' },
{ role: 'assistant', content: 'Code is the poetry of machines, Where logic weaves through screens and seams.' }
]
});
// 之後繼續對話,它就會記得這段歷史
const response2 = await sessionWithHistory.prompt("Can you explain the first line?");
console.log("Assistant:", response2);
// 記得不用時要銷毀 session 釋放記憶體
session.destroy();
sessionWithHistory.destroy();
}

如果你有串過 OpenAI 的模型那一定很熟悉 Chat ML (Chat Markup Language) 格式感覺的酬載,這套 System / User / Assistant 的 Payload 在 Prompt API 當中也一樣,其實很多家供應商的模式都差不多:

<|im_start|>system
You are a helpful and friendly assistant.<|im_end|>
<|im_start|>user
Can you write a short poem about coding?<|im_end|>
<|im_start|>assistant
Code is the poetry of machines,
Where logic weaves through screens and seams.<|im_end|>
根據以上模式建了一個運用 Prompt API 的 local-ai-assistance🔗 MVP 專案實驗

但從單純的接詞機器 LLM 到成為獨當一面的 Agent 還是不足的,可以利用額外的套件像是 LangChain 去做 LLM 的 Harness。

LangChain

像是前面的案例會需要自己手動管理不同 AI 供應商來源、長期記憶、記憶檢索(RAG、Embedding)、工作流搭建、結構化回應或工具調用⋯⋯等延伸問題,如果每個專案都要從頭刻這些基礎建設成本將非常高,LangChain🔗就是為了解決這些痛點而生的框架。

LangChain vs. LangGraph vs. Deep Agents

LangChain 開始文件🔗 會看到三種開始方式:

方式定義與核心功能描述
LangChain一個極簡、可配置的 Agent 框架。您可以根據需求,精準組合模型、工具、提示詞(Prompts)和中間件(Middleware)。
LangGraph適用於具狀態、長期執行 Agent 的底層編排:提供持久化執行、序列流(Streaming)、記憶以及人機協同(Human-in-the-loop)功能。
Deep Agent建立用於複雜、長期執行任務的 Agent。具備完整的 Agent 運作架構,內建規劃、子 Agent、虛擬檔案系統與長期記憶功能。這是最快上手的途徑。
  • Deep Agents:最適合一開始就想要「功能齊全」Agent 的開發者。內建了自動上下文壓縮(automatic context compression)、虛擬檔案系統(virtual filesystem)以及子代理生成(subagent-spawning)等功能。Deep Agents 是建立在 LangChain 代理之上,你也可以選擇直接使用 LangChain 代理。
  • LangChain:適合需要高度自訂化框架的場景,讓你能夠輕鬆地根據特定的使用案例和數據進行調整。
  • LangGraph:底層流程協調框架,專為需要結合「確定性流程」與「自主代理流程」的進階需求而設計。

三者的關係可以理解為:LangGraph 是流程引擎,LangChain 是建立在它上面的框架,Deep Agent 是更高階的開箱即用方案

LangChain 解決什麼?

產品對於 AI 的期望已經不止於回答問題,而是期望一個智能對象能解決問題,開發上麻煩的不是模型本身,而是周圍的一堆整合工作

假設今天用 OpenAI,明天改成 Ollama;需要替 prompt 加上固定的 System Message;要讀 PDF、切文件,再接上向量資料庫。這些事情每項都不難,但全部串在一起後,程式很容易變得瑣碎難維護,LangChain 做的事情,就是把這些常見需求抽象成一致的介面。

LangGraph 解決什麼?

使用有向圖管理 LLM 流程邏輯

如果 LangChain 解決的是「元件怎麼接」,那 LangGraph 解決的就是「整個流程怎麼跑」。如果全部自己寫,通常就是大量的 if...else 或巢狀函式呼叫,流程一變複雜開始會出現分支、重試、共享狀態等需求,閱讀和維護都會愈來愈困難。

收到問題 → 判斷是否需要改寫 → 檢索文件 → 生成答案 → 檢查結果是否符合預期 → 必要時重新執行某個步驟

LangGraph 用有向圖的方式來描述這類流程。

  • Node(節點):代表一個執行步驟,例如改寫問題、檢索文件或產生回答,本質上就是一個普通函式。
  • Edge(邊):定義節點之間的執行順序。
  • Conditional Edge(條件邊):根據目前的狀態決定下一個要執行哪個節點,例如第一輪對話直接檢索,後續對話才先改寫問題。
  • State(狀態):使用 Annotation 定義共享狀態,每個節點都可以讀取或更新,資料會在節點之間自動傳遞。

流程大概會長這樣:

// 1. 定義狀態結構
const MyState = Annotation.Root({
...MessagesAnnotation.spec,
rephrasedQuestion: Annotation<string>,
sourceDocuments: Annotation<Document[]>,
});
// 2. 定義節點
const nodeA = async (state) => {
return { rephrasedQuestion: "改寫後的問題" };
};
// 3. 組裝流程
const graph = new StateGraph(MyState)
.addNode("nodeA", nodeA)
.addNode("nodeB", nodeB)
.addConditionalEdges("__start__", (state) => {
return state.messages.length > 1 ? "nodeA" : "nodeB";
})
.addEdge("nodeA", "nodeB")
.compile();

拆解 fully-local-pdf-chatbot 案例

Effectively Building with LLMs in the Browser with Jacob - LangChain🔗 影片中提到 fully-local-pdf-chatbot🔗 專案運用 LangChain 串上 3 種本地方案達成對用戶上傳的 PDF 文件提問的功能,以下是我拆解這個專案的筆記。

技術棧

核心邏輯全部跑在 Web Worker🔗 裡(worker.ts🔗),避免阻塞主線程,重點是所有東西都可以在客戶端跑完

職責工具
LLM 推論Ollama🔗(本地桌面)/ WebLLM🔗(瀏覽器 WebGPU)/ Chrome AI🔗(瀏覽器內建 Gemini Nano)
EmbeddingTransformers.js🔗(Xenova/all-MiniLM-L6-v2)
向量資料庫Voy🔗(WASM,完全在瀏覽器中運作)
流程編排LangChain.js🔗 + LangGraph.js🔗

第一步:PDF 嵌入

當用戶上傳 PDF 後,Worker 會執行以下流程:

import { WebPDFLoader } from "@langchain/community/document_loaders/web/pdf";
import { RecursiveCharacterTextSplitter } from "langchain/text_splitter";
import { Voy as VoyClient } from "voy-search";
import { HuggingFaceTransformersEmbeddings } from "@langchain/community/embeddings/hf_transformers";
const embeddings = new HuggingFaceTransformersEmbeddings({
modelName: "Xenova/all-MiniLM-L6-v2",
// Can use "nomic-ai/nomic-embed-text-v1" for more powerful but slower embeddings
// modelName: "nomic-ai/nomic-embed-text-v1",
});
const voyClient = new VoyClient();
const vectorstore = new VoyVectorStore(voyClient, embeddings);
const embedPDF = async (pdfBlob: Blob) => {
// 1. 用 LangChain 的 WebPDFLoader 解析 PDF
const pdfLoader = new WebPDFLoader(pdfBlob, { parsedItemSeparator: " " });
const docs = await pdfLoader.load();
// 2. 用 RecursiveCharacterTextSplitter 切成小塊
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 500,
chunkOverlap: 50,
});
const splitDocs = await splitter.splitDocuments(docs);
// 3. 透過 Transformers.js Embedding 轉換後存入 Voy 向量資料庫
await vectorstore.addDocuments(splitDocs);
};

這裡 LangChain 的價值在於:WebPDFLoaderRecursiveCharacterTextSplitterVoyVectorStore 這些元件都是標準化的接口,你不需要自己去處理 PDF 解析或 Chunk 切割的邊界條件。

第二步:RAG 對話流程

當用戶提問時,專案用 LangGraph 建了一個三節點的狀態圖來處理 RAG 流程:

多輪對話

首輪對話

start

rephraseQuestion

retrieveSourceDocuments

generateResponse

對應到程式碼:

const graph = new StateGraph(RAGStateAnnotation)
.addNode("rephraseQuestion", rephraseQuestion)
.addNode("retrieveSourceDocuments", retrieveSourceDocuments)
.addNode("generateResponse", generateResponse)
.addConditionalEdges("__start__", async (state) => {
// 首輪對話直接檢索;多輪對話先改寫問題再檢索
if (state.messages.length > 1) {
return "rephraseQuestion";
}
return "retrieveSourceDocuments";
})
.addEdge("rephraseQuestion", "retrieveSourceDocuments")
.addEdge("retrieveSourceDocuments", "generateResponse")
.compile();

三個節點各自負責:

  1. rephraseQuestion — 如果已經是多輪對話,先用 LLM 將最新的問題結合上下文改寫成獨立的搜尋查詢,提升檢索品質(例如用戶問「那第二章呢?」→ 改寫成「文件第二章的內容是什麼?」)
  2. retrieveSourceDocuments — 拿改寫後的查詢去向量資料庫做相似度搜尋,找出最相關的文件片段
  3. generateResponse — 把檢索到的文件片段塞進 System Prompt 的 <context> 區塊,讓 LLM 基於這些資料生成回答

第三步:三種本地 LLM 供應商的抽換

LangChain 最大的好處之一就是統一介面,專案中三種模型的切換只需要替換實例化的 class:

let model;
if (modelProvider === "webllm") {
model = new ChatWebLLM(modelConfig); // 瀏覽器內 WebGPU 推論
} else if (modelProvider === "chrome_ai") {
model = new ChromeAI(modelConfig); // Chrome 內建 Gemini Nano
} else {
model = new ChatOllama(modelConfig); // 本地 Ollama 伺服器
}

同一套 RAG Pipeline 完全不需要因為底層模型不同而改動邏輯,這就是框架抽象層帶來的好處。唯一需要注意的是 Chrome AI 目前是 text-in/text-out 的 LLM(非 Chat Model),所以在 Prompt 格式上需要特別處理:對話歷史必須手動拼接成字串。

小結

拆解這個專案後可以發現 LangChain 的核心價值:

  • 文件處理標準化WebPDFLoader + RecursiveCharacterTextSplitter 幾行程式碼就能完成 PDF 到模型可檢索的資料格式
  • 向量檢索抽象 — 不管底層用的是 Voy、Pinecone 還是其他向量資料庫,API 一致
  • 模型供應商可抽換 — Ollama / WebLLM / Chrome AI 共用同一套流程
  • LangGraph 狀態機 — 用聲明式的方式描述 RAG 流程中的節點與條件分支,比手動寫 if-else 更易讀、易維護

透過拆解 fully-local-pdf-chatbot 專案了解整個 LangChain 的使用情境與真實流程。

延伸閱讀