這是系列的第二篇。第一篇準備好了 Profile,這篇要寫助理的核心 krl.py,它做兩件事:
- 在模組層級建立一個
default_agent:模型、檔案與 shell 工具、system prompt、對話壓縮、執行前審核。 - 包一個
Assistant類別,讓介面只要呼叫get_feedback(),就能一邊收到回答。
get_feedback() 是 async generator,會吐出三種東西,第三篇的介面就是根據型別決定怎麼顯示:
| 型別 | 意思 |
|---|---|
str | AI 回答的一小段文字 |
dict | 一筆工具資訊(呼叫了什麼工具,或工具執行的結果) |
list | agent 停下來等你核准的請求清單 |
完整程式碼
檔案位置:src/krl.py
import asyncio
import uuid
from typing import Optional
from pathlib import Path
from collections.abc import AsyncIterator
from deepagents import create_deep_agent
from deepagents.backends.local_shell import LocalShellBackend
from dotenv import dotenv_values
from langchain_core.messages import AIMessage, AIMessageChunk, ToolMessage
from langchain_openai import ChatOpenAI
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import Command
from deepagents.middleware.summarization import SummarizationMiddleware
from cfg import Profile
profile = Profile()
chat_model = ChatOpenAI(
model=profile.secret["MODEL_NAME"],
base_url=profile.secret["MODEL_URL"],
api_key=profile.secret["API_KEY"],
)
shell_backend = LocalShellBackend(
root_dir=profile.workspace,
virtual_mode=True,
inherit_env=True,
)
system_prompt = (
"你是使用者的個人助理,預設使用繁體中文回答。"
f"系統是 {profile.os} 環境,請使用這個系統的指令語法。"
# 檔案工具:只看得到工作資料夾,路徑用 / 開頭。
"read_file、write_file、edit_file、ls 等檔案工具只能存取工作資料夾,"
"路徑一律用 / 開頭,路徑 / 就代表工作資料夾,例如 /result.txt, /sample.py 等。"
"不要在檔案工具的路徑裡寫 C: 這種磁碟代號。"
# execute:可以碰到整台電腦,目前位置就是工作資料夾。
"execute 工具執行 shell 指令時,目前所在位置就是工作資料夾,"
"可以用完整路徑存取電腦上的其他檔案。"
# 使用者給的檔案:先複製進來,不動原檔。
"當使用者提供工作資料夾以外的檔案時,"
"先用 execute 把它複製到工作資料夾,再用檔案工具讀取。"
"**只能複製**原本的檔案。"
"**禁止移動或修改**原本的檔案。"
# 圖片和聲音:用 read_file 讀。
"要看圖片或聽聲音時,用 read_file 讀取,如果你有能力,你可以看到圖片、聽到聲音。"
"你產生的暫存檔和分析結果,都存在工作資料夾裡。"
# 回報位置:給使用者看實際路徑,不要給 / 開頭的虛擬路徑。
f"工作資料夾在電腦上的實際位置是 {profile.workspace}。"
"告訴使用者檔案存在哪裡時,要給實際位置,"
f"例如 /content.txt 要說成 {profile.workspace / 'content.txt'} 等。"
)
default_agent = create_deep_agent(
model=chat_model,
system_prompt=system_prompt,
backend=shell_backend,
middleware=[
SummarizationMiddleware(
model=chat_model,
backend=shell_backend,
# 對話觸碰條件就開始壓縮。
trigger=[("tokens", 20000), ("messages", 50)],
# 壓縮後保留最後 20 則訊息原文,其餘變成摘要。
keep=("messages", 20),
)
],
interrupt_on={
"execute": {"allowed_decisions": ["approve", "reject"]},
},
checkpointer=InMemorySaver(),
)
class Assistant:
def __init__(self, thread_id: str) -> None:
"""使用預設的 deep agent,並開一段新的對話"""
self.thread_id = thread_id
return
async def get_feedback(
self,
question_content: Optional[str],
approval_decisions: Optional[list[bool]]
) -> AsyncIterator[str | list | dict]:
"""問 agent 問題或回答核准,回答一邊產生一邊回傳文字片段"""
# 如果有審核,彙整審核結果。
if approval_decisions is not None and question_content is None:
# 把 TUI 給的 True/False,翻成 agent 看得懂的 approve/reject。
decision_contents = []
for approved_item in approval_decisions:
decision_type = "approve" if approved_item else "reject"
decision_contents.append({"type": decision_type})
continue
stream_payload = Command(resume={"decisions": decision_contents})
pass
elif approval_decisions is None and question_content is not None:
# 沒有審核,就是使用者問了新問題。
stream_payload = {
"messages": [{"role": "user", "content": question_content}]
}
elif approval_decisions is not None and question_content is not None:
raise ValueError("問題和審核結果只能給一個,不能兩個都給")
elif approval_decisions is None and question_content is None:
raise ValueError("問題和審核結果至少要給一個")
# 用 thread_id 讓 agent 知道這是哪一段對話。
stream_config = {"configurable": {"thread_id": self.thread_id}}
# 一邊收 agent 的輸出,一邊把文字交給 TUI。
async for stream_part in default_agent.astream(
stream_payload,
config=stream_config,
stream_mode=["messages", "updates"],
version="v2"
):
# 狀態更新裡只挑出工具資訊,一筆一筆交給 TUI。
part_type = stream_part["type"]
if part_type == "updates":
tool_events = self.get_tool_events(stream_part["data"])
for tool_event in tool_events:
yield tool_event
continue
continue
# 剩下只處理對話文字,其他類型略過。
if part_type != "messages":
continue
# 不是 AI 的回答、空字串、上下文壓縮時產生的摘要,都不顯示。
message_chunk, message_metadata = stream_part["data"]
if (
not isinstance(message_chunk, AIMessageChunk) or
not message_chunk.text or
dict(message_metadata).get("lc_source") == "summarization"
):
continue
yield message_chunk.text
continue
# stream 停下來後,如果 agent 在等審批,把請求清單交給 TUI。
agent_state = await default_agent.aget_state(stream_config)
approval_requests = []
for agent_interrupt in agent_state.interrupts:
action_requests = agent_interrupt.value["action_requests"]
approval_requests.extend(action_requests)
continue
# 沒有要審批的就結束;有的話把整份清單交給 TUI。
if approval_requests==[]: return
yield approval_requests
def get_tool_events(self, update_data: dict) -> list[dict]:
"""從一次狀態更新裡,挑出工具呼叫和工具結果"""
tool_events = []
# 更新的格式是 {步驟名稱: 這一步改了什麼}。
for node_name, node_update in update_data.items():
# 同一步寫了好幾次時會是 list,統一變成 list 來處理。
update_items = node_update
if not isinstance(node_update, list):
update_items = [node_update]
for update_item in update_items:
# 這一步沒有改任何東西(None),或不是一般的更新,略過。
if not isinstance(update_item, dict):
continue
update_messages = update_item.get("messages")
if not isinstance(update_messages, list):
continue
for update_message in update_messages:
# AI 決定要呼叫工具:記下名稱和參數。
# 只看 model 這一步,因為審批的步驟會把同一個呼叫再送一次。
if (
node_name == "model" and
isinstance(update_message, AIMessage)
):
for tool_call in update_message.tool_calls:
tool_events.append({
"type": "tool_call",
"name": tool_call["name"],
"args": tool_call["args"],
})
continue
# 工具執行完:記下成功或失敗,以及結果。
# 圖片、聲音這類不是文字的結果,只寫一句說明。
if isinstance(update_message, ToolMessage):
result_content = update_message.content
if not isinstance(result_content, str):
result_content = "(非文字內容)"
tool_events.append({
"type": "tool_result",
"name": update_message.name,
"status": update_message.status,
"content": result_content,
})
continue
continue
continue
return tool_events
pass
if __name__ == "__main__":
pass
下面逐段說明。
1. 模型與 backend
profile = Profile()
chat_model = ChatOpenAI(
model=profile.secret["MODEL_NAME"],
base_url=profile.secret["MODEL_URL"],
api_key=profile.secret["API_KEY"],
)
shell_backend = LocalShellBackend(
root_dir=profile.workspace,
virtual_mode=True,
inherit_env=True,
)
ChatOpenAI 加上 base_url,就能接任何相容 OpenAI 格式的服務,三個值都來自 .env。
LocalShellBackend 是 agent 的「手」,提供兩類工具:
- 檔案工具(
read_file、write_file、edit_file、ls…):virtual_mode=True時,檔案工具只看得到root_dir,也就是workspace/。路徑/就代表工作資料夾,agent 碰不到外面的檔案。 execute:執行 shell 指令。它的起始位置是工作資料夾,但可以存取整台電腦,所以後面會設定成每次執行前都要你核准。inherit_env=True讓指令沿用你目前的環境變數,例如PATH。
2. System prompt
System prompt 的重點都跟上面的 backend 設計有關:
- 檔案工具的路徑一律用
/開頭,不要寫C:這種磁碟代號。 - 使用者給了工作資料夾以外的檔案,先用
execute複製進來,再用檔案工具讀取;不准移動或修改原檔。 - 告訴使用者檔案位置時,要把
/content.txt這種虛擬路徑換成電腦上的實際路徑。
最後一條很實用。如果不寫,模型會回答「檔案存在 /result.txt」,使用者到根目錄找不到檔案。這裡用 f-string 把 profile.workspace 直接放進 prompt,模型就有實際路徑可以替換。
3. 組裝 agent
default_agent = create_deep_agent(
model=chat_model,
system_prompt=system_prompt,
backend=shell_backend,
middleware=[
SummarizationMiddleware(
model=chat_model,
backend=shell_backend,
trigger=[("tokens", 20000), ("messages", 50)],
keep=("messages", 20),
)
],
interrupt_on={
"execute": {"allowed_decisions": ["approve", "reject"]},
},
checkpointer=InMemorySaver(),
)
SummarizationMiddleware:對話超過 20000 tokens 或 50 則訊息時,把較舊的對話壓縮成摘要,只保留最後 20 則原文。這兩個數字可以依模型的上下文長度調整。interrupt_on:agent 要呼叫execute時會先暫停,等外部送回approve或reject才繼續。這就是「執行前先問你」的機制。checkpointer=InMemorySaver():把每段對話的狀態存在記憶體。暫停後能從原處繼續,也能記得上下文,都靠它。關掉程式後記錄就會消失。
4. get_feedback():送出問題或審核結果
這個方法有兩種用法,兩個參數只能給一個:
# 問新問題
assistant.get_feedback("幫我列出工作資料夾的檔案", None)
# agent 暫停等審核時,送回審核結果(一個請求對應一個 True/False)
assistant.get_feedback(None, [True, False])
問問題時,payload 是一般的訊息格式。送審核結果時,要用 LangGraph 的 Command(resume=...),把 True/False 轉成 {"type": "approve"} 或 {"type": "reject"},agent 才會從暫停的地方繼續。
串流:同時收 messages 和 updates
async for stream_part in default_agent.astream(
stream_payload,
config=stream_config,
stream_mode=["messages", "updates"],
version="v2"
):
messages串流是模型一個字一個字產生的片段,用來做打字效果。updates串流是每一步(模型、工具)完成後的狀態變化,用來抓工具呼叫與結果。
收到 messages 片段時,有三種情況不顯示:不是 AI 的訊息、空字串、lc_source == "summarization"。最後一種是壓縮對話時模型產生的摘要,不加這個條件的話,摘要內容會混進回答裡。
串流結束後檢查是否在等審核
agent_state = await default_agent.aget_state(stream_config)
approval_requests = []
for agent_interrupt in agent_state.interrupts:
action_requests = agent_interrupt.value["action_requests"]
approval_requests.extend(action_requests)
串流停下來有兩種可能:agent 回答完了,或在等審核。讀一次狀態,如果 interrupts 裡有東西,就把每個請求(含 name 和 args)整理成一個 list 交給介面。模型可能一次呼叫好幾個 execute,所以是一份清單,不是單一請求。
5. get_tool_events():從狀態更新挑出工具資訊
updates 的格式是 {步驟名稱: 這一步改了什麼},這個方法把它攤平成一筆一筆的 dict:
{"type": "tool_call", "name": "execute", "args": {"command": "ls"}}
{"type": "tool_result", "name": "execute", "status": "success", "content": "a.txt\nb.txt"}
有兩個細節:
- 工具呼叫只看
model這一步。審核的步驟會把同一個 AIMessage 再送一次,不過濾的話,同一個呼叫會在畫面上出現兩次。 - 工具結果不是文字時(例如
read_file讀圖片),只顯示「(非文字內容)」,避免把一大串 base64 印到畫面上。
自己測試
還沒有介面也能先測試 Assistant。照第一篇用 pip install -e . 安裝專案後,在放有 .env 的資料夾執行:
import asyncio
import uuid
from krl import Assistant
async def main():
assistant = Assistant(thread_id=str(uuid.uuid4()))
async for piece in assistant.get_feedback("你好,介紹一下你自己", None):
if isinstance(piece, str):
print(piece, end="", flush=True)
else:
print("\n", piece)
asyncio.run(main())
下一步
第三篇會用 Textual 做出聊天介面:根據 get_feedback() 吐出的型別顯示文字或工具資訊,遇到審核請求就跳出核准視窗。