用 8 支可直接執行的 Python 腳本,從呼叫模型一路學到 Agent 與 RAG,全程只用地端 Ollama,不需雲端 API Key。
需要一台跑 Ollama 的主機,裝好對話模型 qwen3.8:27b 與向量模型 bge-m3;本機需 Python 3.11 以上。
pip install langchain langchain-ollama langgraph pypdf
ollama pull qwen3.8:27b
ollama pull bge-m3
| 課 | 檔案 | 主題 | 重點 |
|---|---|---|---|
| 0 | config.py |
共用設定 | 模型只在這裡設定一次 |
| 1 | 01_hello.py |
invoke / stream | 跟模型講話的兩種方式 |
| 2 | 02_chain.py |
Prompt 模板 + LCEL | 用 | 把步驟串成管線 |
| 3 | 03_structured.py |
結構化輸出 | 讓模型直接回 Python 物件 |
| 4 | 04_tools.py |
Tool calling | 模型決定要呼叫哪個函式 |
| 5 | 05_agent.py |
Agent | 自動跑「呼叫工具、看結果、再決定」 |
| 6 | 06_memory.py |
對話記憶 | 多輪對話記得前文 |
| 7 | 07_rag_basic.py |
RAG 原理 | 先找資料再回答 |
| 8 | 08_rag_docs.py |
文件 RAG | 拿真實 PDF 做問答 |
每課都可直接執行:python 01_hello.py。中文亂碼時先設環境變數 PYTHONIOENCODING=utf-8。
from langchain_ollama import ChatOllama, OllamaEmbeddings
OLLAMA_URL = "http://<你的 Ollama 主機>:11434"
MODEL = "qwen3.8:27b"
EMBED_MODEL = "bge-m3"
def get_llm(reasoning: bool = False, temperature: float = 0.3) -> ChatOllama:
return ChatOllama(base_url=OLLAMA_URL, model=MODEL,
temperature=temperature, reasoning=reasoning, keep_alive="30m")
def get_embeddings() -> OllamaEmbeddings:
return OllamaEmbeddings(base_url=OLLAMA_URL, model=EMBED_MODEL)
ChatOllama:換成 ChatOpenAI、ChatAnthropic 等只需改這一行,後面各課不用動,這是 LangChain 最大的好處。temperature:0 穩定、1 有創意。查資料與抽取用低值,寫文案用高值。reasoning:qwen3.8 是會思考的模型。False 直接回答較快(本教學預設);True 先推理再回答,較準但慢數倍,思考內容在 msg.additional_kwargs["reasoning_content"]。keep_alive="30m":模型常駐 GPU 30 分鐘,第一次呼叫要載入模型會較慢。invoke 等全部生完才回傳,適合程式處理結果;stream 邊生成邊吐字,適合聊天介面。
from langchain_core.messages import HumanMessage, SystemMessage
from config import get_llm
llm = get_llm()
msg = llm.invoke([
SystemMessage("你是簡潔的助理,用繁體中文回答。"),
HumanMessage("用一句話解釋什麼是 LangChain。"),
])
print(msg.content) # 回答文字
print(msg.usage_metadata) # token 用量
for chunk in llm.stream("列出三個學 LangChain 的重點"):
print(chunk.content, end="", flush=True)
| 類別 | 角色 | 用途 |
|---|---|---|
SystemMessage |
system | 設定人設、規則、輸出語言 |
HumanMessage |
user | 使用者說的話 |
AIMessage |
assistant | 模型的回答,invoke 回傳的就是它 |
只傳一個字串也可以(llm.invoke("你好")),會自動包成 HumanMessage。
練習
get_llm(reasoning=True),印出 msg.additional_kwargs 看思考過程,並比較速度。| 是 LangChain 的核心語法:把上一步的輸出交給下一步當輸入。
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
prompt = ChatPromptTemplate.from_messages([
("system", "你是 {role},用繁體中文、{style} 的語氣回答。"),
("human", "{question}"),
])
chain = prompt | get_llm() | StrOutputParser()
chain.invoke({"role": "資深 MIS 工程師", "style": "口語", "question": "VLAN 是什麼?"})
資料流向:dict → prompt(訊息串列)→ llm(AIMessage)→ StrOutputParser(純字串)。
每個元件都是 Runnable,共用同一組方法:
| 方法 | 用途 |
|---|---|
.invoke(x) |
跑一次 |
.stream(x) |
串流輸出 |
.batch([x1, x2]) |
一次跑多組,平行送出 |
.ainvoke(x) |
非同步版本,搭配 FastAPI 等 |
用模板而不用 f-string 的好處:少給變數會直接報錯;模板可重複使用、單獨測試,也能存成檔案讓非工程師修改。
練習
| (lambda s: s[:100]),只取前 100 字(一般函式也能串)。{text, lang},輸出翻譯結果。實務上最常用的功能:讓模型直接回傳 Pydantic 物件,不用自己解析文字。
from pydantic import BaseModel, Field
class Applicant(BaseModel):
name: str | None = Field(description="應徵者姓名")
age: int | None = Field(description="年齡")
skills: list[str] = Field(description="技能清單")
summary: str = Field(description="一句話摘要")
llm = get_llm().with_structured_output(Applicant)
result = llm.invoke(f"從下列履歷抽出資料:\n{text}")
result.skills # ['Windows Server', 'AD', 'VMware', 'C#', 'SQL']
原理:Pydantic 類別轉成 JSON Schema 交給 Ollama,Ollama 強制模型輸出符合格式的 JSON,再轉回物件。
Field(description=...) 很重要,模型靠描述理解欄位意思。str | None,否則模型會硬編一個值。Literal:level: Literal["高", "中", "低"]。常見應用:履歷抽取、發票或報價單辨識、客訴分類、把自然語言轉成查詢條件。
練習
fit: Literal["適合", "待評估", "不適合"],在 prompt 附上職缺需求,讓模型順便評估。product、issue_type、urgency。模型只決定要呼叫哪個函式、帶什麼參數;函式由你的程式執行,所以工具裡可以查資料庫或內部系統。
from langchain_core.messages import HumanMessage, ToolMessage
from langchain_core.tools import tool
@tool
def get_stock(part_no: str) -> int:
"""查詢料號的庫存數量。""" # docstring 就是給模型看的說明,必寫
return {"A001": 120, "B205": 0}.get(part_no, -1)
llm = get_llm().bind_tools([get_stock, multiply])
messages = [HumanMessage("A001 庫存多少?每個 35 元總價多少?")]
while True:
ai = llm.invoke(messages)
messages.append(ai)
if not ai.tool_calls: # 不再要求工具 → 結束
break
for call in ai.tool_calls:
output = tools[call["name"]].invoke(call["args"])
messages.append(ToolMessage(str(output), tool_call_id=call["id"]))
實際執行的流程:
get_stock(part_no="A001")。ToolMessage 傳回。multiply(a=120, b=35)。@tool 從函式名稱、型別標註、docstring 自動產生工具說明,三者都要寫清楚。create_agent 幫你跑完第 4 課的 while 迴圈。
from langchain.agents import create_agent
agent = create_agent(
model=get_llm(),
tools=[get_stock, get_price],
system_prompt="你是倉管助理,用繁體中文回答,需要資料時一定要呼叫工具,不要猜。",
)
result = agent.invoke({"messages": [{"role": "user", "content": "A001、B205、C330 哪個缺貨?"}]})
for m in result["messages"]:
m.pretty_print() # 問題 → 工具呼叫 → 工具結果 → 回答
實測時模型對 3 個料號各查庫存與單價,再整理成表格並指出缺貨料號。
| 手寫迴圈(第 4 課) | create_agent(第 5 課) | |
|---|---|---|
| 程式碼量 | 多 | 少 |
| 控制度 | 完全掌控每一步 | 交給框架 |
| 進階功能 | 自己寫 | 內建記憶、middleware、串流 |
| 適合 | 學原理、特殊流程 | 大多數實務情境 |
create_agent 底層是 LangGraph;需要分支、人工審核、平行處理時再直接學 LangGraph。LLM 本身沒有記憶;所謂記憶,是把之前的對話一起送進去。
from langgraph.checkpoint.memory import InMemorySaver
agent = create_agent(model=get_llm(), tools=[],
system_prompt="...", checkpointer=InMemorySaver())
cfg = {"configurable": {"thread_id": "demo-1"}}
agent.invoke({"messages": [{"role": "user", "content": "我叫小明"}]}, cfg)
agent.invoke({"messages": [{"role": "user", "content": "我叫什麼?"}]}, cfg) # → 小明
checkpointer 保存對話狀態。InMemorySaver 存在記憶體,程式關掉就消失;正式系統換 SQLite 或 Postgres 版本。thread_id 區分不同對話,實務上通常是使用者 ID 或對話 ID。換一個就是全新對話。練習:把 thread_id 改成用 input() 輸入,模擬兩個使用者輪流聊天,確認記憶不會混在一起。
RAG(檢索增強生成):模型不知道內部資料,所以先找出相關段落、塞進 prompt,再請它根據內容回答。
bge-m3 轉成向量,存進向量資料庫。qwen3.8:27b 回答。bge-m3 把一段文字轉成 1024 個數字,意思相近的文字向量也相近,所以搜尋看的是語意而非關鍵字。實測問「我想在家連公司電腦」,排第一的是 VPN 申請規定,原文並沒有「在家」二字。
from langchain_core.vectorstores import InMemoryVectorStore
from langchain_core.runnables import RunnablePassthrough
store = InMemoryVectorStore.from_documents(docs, get_embeddings())
retriever = store.as_retriever(search_kwargs={"k": 2})
prompt = ChatPromptTemplate.from_template(
"只根據下列資料回答,資料沒有就說「資料中沒有」,並註明出處。\n\n"
"資料:\n{context}\n\n問題:{question}")
chain = (
{"context": retriever | format_docs, "question": RunnablePassthrough()}
| prompt | get_llm() | StrOutputParser()
)
context 由檢索得到,RunnablePassthrough() 把原問題原封不動往下傳。把 PDF、TXT、MD 放進 docs 資料夾即可問答;索引存成 index.json,下次不用重算。
python 08_rag_docs.py --rebuild # 第一次,或文件有變動時
python 08_rag_docs.py # 互動問答,輸入 q 離開
python 08_rag_docs.py "密碼規定是什麼?" # 直接問
pypdf 逐頁讀取,記下檔名與頁碼供回答註明出處。store.dump("index.json"),之後 InMemoryVectorStore.load(...)。RecursiveCharacterTextSplitter(
chunk_size=300, # 每塊約幾個字
chunk_overlap=50, # 相鄰兩塊重疊,避免句子被切斷
separators=["\n\n", "\n", "。", ";", ",", ""], # 中文優先在段落、句號切開
)
| 現象 | 調整方向 |
|---|---|
| 答案缺頭缺尾 | 加大 chunk_size 或 chunk_overlap |
| 混進不相關內容 | 減小 chunk_size 或減少 k |
| 文件有卻說沒有 | 增加 k;先單獨用 similarity_search 看檢索結果 |
| 某份 PDF 完全問不到 | 可能是掃描版,pypdf 抽不出文字,需先 OCR |
RAG 答錯時先檢查檢索結果,再看模型;多數問題出在檢索。
練習
--rebuild 後實際問問看。chunk_size 改成 150 與 800,用同一個問題比較答案。| 問題 | 原因與處理 |
|---|---|
| 第一次執行很慢 | 模型約 17GB,要從硬碟載入 GPU,之後常駐 30 分鐘 |
| Connection refused | 瀏覽器開 http://<Ollama 主機>:11434/api/tags,確認看得到兩個模型 |
| 回答變簡體中文 | system prompt 寫明「使用繁體中文(台灣用語)」 |
| 想換雲端模型 | 只改 config.py 的 get_llm(),例如 ChatOpenAI(model="gpt-4o-mini"),API Key 用環境變數設定,不要寫進程式 |
下一步
@tool,做能用中文查庫存、訂單的 agent(第 5 課實戰版)。參考資料