我是一名剛升大一新鮮人,暑假期間,無意間看到了一篇有關於自己打造 Agent 的文章,我也想來自己試試看!
受 https://github.com/Windy3f3f3f3f/claude-code-from-scratch 啟發。
因為我想要練習 Python 的能力,基本上每一行都是我手打出來的,當然!也有讓 AI 給我不少引導。
這專案大概是從 7 月開始陸陸續續進行,也因為沒有用 vibe-coding,所以推進得非常慢,本來打算一次整理好全部內容再開始發文,但是參賽時間迫在眉睫啦,哈哈!
所以現在,我也不確定整份專案會完成到什麼程度,但我可以跟各位保證的是已經有「可以完整使用」的程度了(可以先去 https://github.com/LeeMatthewCat/code-notes 看看),發佈這篇的當下,我已經在撰寫 Day - 18 的內容,目前已經有的功能是:
而還剩 10 多篇內容,我目前還想達成的是:
然後,剛剛有說過我想練習 Python,簡單來說,我才剛學 Python 不久,所以可能有些地方會有錯,或是有比我更好的寫法,還請大家指出!
如果你也是新手,可以去看看我和 AI 一起合力編輯的筆記 https://github.com/LeeMatthewCat/code-notes ,文章裡面有出現的函數或功能大部分在這份筆記裡都有。
我文章中程式碼的部分是拆成一部分一部分講解的,我有盡量把前後的部分寫清楚,不至於看不出來這段在哪,想看更完整的部分可以到 GitHub(有些部分寫得可能跟文章不大一樣,自己斟酌一下)。
那麼,我們開始吧!
我們先用最簡單的方式來讓模型回答我們的問題:
# src/meowgent/agent.py
import ollama
if __name__ == "__main__":
user_input = input("輸入問題:") # 獲取用戶輸入
response = ollama.chat(
model="qwen3.8", # 請替換為本地實際下載的模型名稱
messages=[
{
"role": "user",
"content": user_input,
}
],
)
print(response.message.content) # 印出模型回傳的文字
如此一來 uv run src/meowgent/agent.py 就能調用模型來回答問題了!
確保 ollama 軟體處於「開啟」狀態,
後續會做 ollama 啟動與關閉的管理(ollama_manager.py中)。
接著,我們來試試對於「多輪對話」的支援:
什麼是多輪對話?
模型其實沒有實質意義上的記憶能力,他的記憶來自於在每一次調用時都把「先前的對話傳入」,這就是所謂的多輪對話!
而我們需要有一個紀錄多輪對話的串列 - history_messages。
# src/meowgent/agent.py
import ollama
if __name__ == "__main__":
history_messages = [] # 初始化多輪對話紀錄
while True: # 對話迴圈
user_input = input("輸入問題:")
history_messages.append(
{
"role": "user",
"content": user_input
}
) # 紀錄下使用者輸入
response = ollama.chat(
model="qwen3.8",
messages=history_messages
)
print(response.message.content)
history_messages.append(
{
"role": "assistant",
"content": response.message.content
}
) # 紀錄下模型的回答,接著重新開始下一個問題
目前我們只使用了 agent.py 一個檔案,但接下來,我們會有越來越多的功能。
一般來說,專案中會在各個檔案中做出分工:
src/meowgent/
├── providers
│ ├── __init__.py
│ ├── base.py
│ └── ollama_provider.py
│
└── agent.py
主要的程式入口我們放在 agent.py,模型調用放在 ollama_provider.py,base.py 負責做規格的定義。
# src/meowgent/providers/__init__.py from .base import ToolCall, LLMResponse, StreamChunk, LLMProvider from .ollama_provider import OllamaProvider
專案中還有做對其他模型提供方式的支援,篇幅原因,本文只討論 ollama 的部分。
接著我們使用 @dataclass 自行定義 StreamChunk 類別,明確將輸出拆分為「推理思考」與「最終回答」兩大部分:
至於為什麼要區分出推理及回答,是因為後期我會將兩者內容做不同效果的顯示,所以就先將它們區分開了。
# src/meowgent/providers/base.py
...
@dataclass
class StreamChunk:
""" 統一串流(流式輸出)協定 """
thinking_chunk: Optional[str] = None
content_chunk: Optional[str] = None
接著我們定義 LLMProvider 類別並繼承 ABC 來規範模型提供者的行為,強制 provider 實作 stream_generate 用來做輸出。
同時我們一邊來實作串流輸出,也就是模型不會等到輸出完所有文字才回傳,而是一部分一部分(chunk)的做回傳。
# src/meowgent/providers/base.py
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Optional, Dict, List, Any, Iterator, Literal
class LLMProvider(ABC):
def __init__(
self,
model_name: str
):
self.model_name = model_name
@abstractmethod
def stream_generate(
self,
history_messages: list
) -> Iterator[StreamChunk]: # 因為串流輸出,回傳的是迭代物件
...
provider 要做到的點只有:
history_messages。agent.py。建立 OllamaProvider 類別,先設定好使用模型(由 agent.py 傳入):super() 繼承 LLMProvider 提到的 model_name。
# src/meowgent/providers/ollama_provider.py
from .base import LLMProvider, StreamChunk
from typing import Iterator, Optional
import ollama
class OllamaProvider(LLMProvider):
def __init__(self, model_name: str):
super().__init__(model_name=model_name)
def stream_generate(
self,
history_messages: list
) -> Iterator[StreamChunk]: # base.py 中規定的實作
...
接著是將對話傳入給模型,這裡我們把 stream=True 加上,來開啟串流輸出:
如果沒有用串流輸出,模型的回答會在「全部完成後」才輸出,體感上響應會慢很多。
# src/meowgent/providers/ollama_provider.py
...
class OllamaProvider(...):
...
def stream_generate(...) -> ...:
response = ollama.chat(
model=self.model_name,
messages=history_messages,
stream=True # 流式輸出文字
)
接著 ollama_provider.py 下方要進行對於串流回傳的處理,在這之前,我們回頭看一下 base.py 說的「stream_generate() 回傳的是裝著 StreamChunk 型別(自訂的型別)的可迭代物件」,
也就是說,ollama_provider.py 要用 yield 的方式一 chunk 一 chunk 的回傳片段給 agent.py。
我們先用 for 把 chunk 取出,接著 yield 傳出:
# src/meowgent/providers/ollama_provider.py
...
class OllamaProvider(...):
def stream_generate(...) -> ...:
...
for chunk in response:
thinking = getattr(chunk.message, "thinking", None)
# 用 getattr 防止模型沒有推理功能(沒有 message.thinking 屬性)
content = chunk.message.content if chunk.message.content else None
# 為空字串則 None
yield StreamChunk(thinking_chunk=thinking, content_chunk=content)
模型的回答放在
chunk.message.content,推理放在chunk.message.thinking裡
如此一來 ollama_provider.py 的部分就完成啦!接著,我們來進行 agent.py。
我們已經把模型的請求部分放到了 ollama_provider.py 裡,
所以,現在 agent.py 只需關注:
後期會把終端的輸入輸出移出到
src/meowgent/cli之中
先做資訊的初始化:
# src/meowgent/agent.py
from providers import OllamaProvider
from rich.console import Console
from rich.markdown import Markdown
from rich.live import Live
from typing import Iterator, Optional
if __name__ == "__main__":
console = Console() # 初始化 rich 終端
model_name = "qwen3.8"
provider = OllamaProvider(model_name)
history_messages = []
接著一樣進入到「對話迴圈」的部分,
# src/meowgent/agent.py
if __name__ == "__main__":
...
while True: # 對話迴圈
user_input = input("輸入問題:")
history_messages.append(
{
"role": "user",
"content": user_input
}
)
response = provider.stream_generate(
history_messages=history_messages
)
# 調用 ollama_provider
再來要進行印出串流的實作,我們採用 live 的方案,用 thinking_full_text 和 result_full_text 不斷收集 response.chunk 吐出的新文字,並整段進行更新:
舉例來說,第一個 chunk 吐出「你」的回答,我們放入
result_full_text並用 live 輸出result_full_text,接著吐出「好」,再次放入並輸出result_full_text,以此類推。
# src/meowgent/agent.py
if __name__ == "__main__":
...
while True:
...
thinking_full_text = ""
result_full_text = ""
with Live(
console=console,
refresh_per_second=10,
vertical_overflow="visible"
) as live:
for chunk in response:
if chunk.thinking_chunk: # 推理
thinking_full_text += chunk.thinking_chunk
live.update(Markdown(
thinking_full_text,
style="dim"
))
# 用灰色來跟模型回答做出區別
if chunk.content_chunk: # 回答
result_full_text += chunk.content_chunk
live.update(Markdown(result_full_text))
history_messages.append(
{
"role": "assistant",
"content": result_full_text
}
) # 最後要再把回答加回多輪紀錄
vertical_overflow目前設為"visible"也就是當輸出內容超過畫面時會自動把畫面向下滾,但是他會跟我們人去手動「滾動頁面」發生衝突,但這個問題,
我們未來在 CLI 章節再去做解決!
今天這篇,我們做出來的東西其實還不能稱作是一個 Agent,它還缺乏一個非常重要的能力 -「工具調用」。
下一篇,我們來進行這個部分!