iT邦幫忙

2026 iThome 鐵人賽

DAY 20
0
Software Development

手刻 AI Agent!大一新生的 Python 實戰筆記系列 第 20 篇

Day 20 - 設定檔 - 用指令來更改設定 - 下

  • 分享至 

  • xImage
  •  

轉眼間已經來到 Day 20 了,也來到了設定檔的最後一篇,馬上設定的功能就可以完善了,我們繼續來處理更改設定的指令 _change_config()!

回顧一下,昨天有說到這個函數要處理的四件事:

昨天上傳文章忘記把照片傳上去(而且昨天那篇圖片很多張啊 T_T),可能有些地方讀起來怪怪的,已經改好了,可以再稍微看一下圖片部分

  1. 動態取得資料模型的格式。
  2. 將取出的項目做選項拼接,顯示效果如下圖供使用者去選擇。
  3. 解析選擇的項目,進到選擇的項目進行設定。
  4. 存檔以及對更新項目做應用。
    前兩項已經完成了,事不宜遲,馬上開始第三項的實作!

解析選項

我們要區分出以下四種:

  1. 選擇退出。
  2. 選擇更改模型。
  3. 選擇更改含選項(參數型別為 Literal 的)的項目。
  4. 選擇更改不含選項的項目(打字輸入)。

前兩者只要分別導向兩個函數處理後結束 _change_config(),分別是等等要新增的 _cancel_select() 和 Day 15 做的 _change_model()。

結束的部分,因為如果選了三或四(二的取消在 _change_model() 裡)後取消,也需要結束的邏輯,所以做成函數。


退出選擇

剛剛有說到退出的部分要導 _cancel_select(),先從這個函數開始吧!
其實很簡單,就只是印出要退出,然後加個分隔線:

# src/meowgent/cli/commands.py
...
@cmd_registry(...)
def _change_config(...):
	""" ... """
	
	def _cancel_select():
        cli.console.print(Padding("[red]退出設定更改[/red]", (0, 0, 0, 2)))
        cli.console.print(cli.get_rule())
    
    choices ...
    ...

調用處則是呼叫完函數用 return 退出 _change_config():

判斷部分的 None 是為了捕捉上方選擇時 ctrl c 退出。

# src/meowgent/cli/commands.py
...
@cmd_registry(...)
def _change_config(...):
	...
	
	select = questionary.select(...).ask()
	
	if select == "離開設定" or select == None:
        _cancel_select()
        return

選擇更改模型

模型的部分也非常簡單,呼叫 _change_model() 後 return 就好了:

# src/meowgent/cli/commands.py
...
@cmd_registry(...)
def _change_config(...):
	...
	
	if select == ("models", "default_model"):
        _change_model(model_object=model_object, cli=cli, config=config)
        return

其他選項

對應到的是第三點以及第四點。

提取項目資訊

首先要把資訊提取出來,分別要有:

  1. 當前值 now_value(拿來做選擇的預設值)。
  2. 項目描述 desc(項目的標題)。
  3. 值的型別 value_type(型別驗證用)。
    取得的方式跟上一篇基本一樣,忘記了可以回去看一下:
# src/meowgent/cli/commands.py
...
@cmd_registry(...)
def _change_config(...):
	...
	
	cat_name, field_name = select # 提取出選擇

    sub_mdoel: BaseModel = getattr(config, cat_name)
    sub_class = sub_mdoel.__class__ # 子類別

    field_info = sub_class.model_fields[field_name] # 取出欄位內的內容
    now_value = getattr(sub_mdoel, field_name) # 當前值
    desc = field_info.description # 描述
    value_type = field_info.annotation # 型別

    new_value = None

new_value = None 要先做宣告是因為等等它會在閉包內部被賦值,以及它是作為未變更情況下阻擋存檔的防護。


選項型別為 Literal

先做這個比較簡單的,只要讀取出 Literal 的選項就行。
對 value_type 使用 get_origin() 取得外層型別,判斷是不是 Literal。

這邊來講一下 get_origin() 和 get_args():
舉例來說,Dict[str, int] 外層型別就是指字典,而字串及整數則是內層。
外層用 get_origin() 得到 typing.Dict;內層用 get_args() 得到 (str, int)。

那這裡來延伸個觀念,如果是 Dict[Dict[str, int], str] 怎麼辦呢?誰是外誰是內?
先做 get_origin(),拿到的是「最外層」,也就是 typing.Dict,然後 get_args() 會拿到「內層全部」- (typing.Dict[str, int], str),而得到這個元組,就能繼續往下扒開了,這邊就不繼續講了。

而研究這部分時,我也發現了件事情,在 Python 3.9 以上 官方不建議從 typing 匯入容器型別了,直接可以用原生替代,也就是不用像以往這樣:

from typing import Dict
test_dict_1 : Dict[str, int] = {"test": 0} # 以往做法

直接不用匯入 test_dict_2 : dict[str, int] = {"test": 0} 這樣就好了。
但為了專案的一致性,這邊我就不改了,先照著原先從 typing 匯入的方式就好。

如果判斷為是,就用 get_args() 拿出放在 Literal 裡的選項,最後在選項裡面加入一個取消:

# src/meowgent/cli/commands.py
...
@cmd_registry(...)
def _change_config(...):
	...
	
	if get_origin(value_type) is Literal:

        options = list(get_args(value_type))
        options.append("取消")

接著馬上放入 questionary.select() 渲染選項,選擇後先判斷是否取消或沒選,是則直接退出:

# src/meowgent/cli/commands.py
...
@cmd_registry(...)
def _change_config(...):
	...
	if get_origin ...:
		...
		
		new_value = questionary.select(
            message=f"請選擇 {desc}:",
            choices=options
        ).ask()

        if new_value == "取消" or new_value == None:
            _cancel_select()
            return

用打字輸入的項目

這部分就比較麻煩了,我們需要「額外做一個函數」用來檢查用戶手動「輸入的值是否合規」,例如如果項目要求是數字而用戶輸入文字的話就不行,範圍設在 0~1.5 但輸入 20 也不行。
條件放在前面 Literal 檢查後面,用 questionary.text() 取得用戶輸入,validate 傳入的就是用來驗證的函數,我們等下馬上來實作。
判斷如果 text_result 得到 None 的話(也就是在輸入的地方用戶退出了)就呼叫 _cancel_select() 來做退出。

你可能會好奇,如果輸入「沒有通過驗證」,那怎麼辦?
https://ithelp.ithome.com.tw/upload/images/20261002/20183608wrZhVcqphx.png
看到上面的圖,左下角有提示「驗證錯誤」,沒錯!只有能通過驗證的輸入才能結束questionary.text()(除非退出)。

# src/meowgent/cli/commands.py
...
@cmd_registry(...)
def _change_config(...):
	...
	if get_origin ...:
		...
		
	else:
        text_result = questionary.text(
            message=f"輸入 {desc} 的值:",
            default=str(now_value),
            # 要求為字串,要避免為數字
            
            validate=_type_validator
            # 會驗證直到通過(True),或 ctrl-c or esc 取消(None)
        ).ask()

        if text_result is None:
        # 如果輸入完值並完成驗證但最後退出選擇(因為驗證是邊輸入就邊在跑的)
        
            _cancel_select()
            return

而有發現一件事嗎,上面完全沒有賦值到 new_value 的動作,也就是說會在函數裡面用 nonlocal 對外部變數 new_value 做修改:

利用了「閉包」概念,簡單來說就是讓內部函數可以「讀取甚至修改外部函數的變數」。
像下面這樣雖然看起來只是簡單的讀取而已,但也是閉包的應用:

def make_multiplier(factor):
    # 外部函式的區域變數:factor
    def multiplier(number):
        # 內部函式引用了外部函式的變數 factor
        return number * factor

    # 回傳內部函式物件本身
    return multiplier

而另一種應用就是 nonlocal new_value 這樣。

# src/meowgent/cli/commands.py
...
@cmd_registry(...)
def _change_config(...):
	def _cancel_select():	
		...
	
	def _type_validator(input: str) -> Optional[str]:
        """ 驗證輸入是否合規 """

        nonlocal new_value # 宣告修改外層變數(閉包)

而驗證的方法就是直接把輸入放入資料模型,看會不會報錯(用 try 捕捉),驗證成功時回傳 True,否則回傳字串。

一開始傳入的是字串,但當我們把字串放入資料模型內 model_validate() 就會自動轉換為正確型別,例如 int,所以賦值時不需要額外做型別轉換。

而回傳的字串就是剛剛圖片中左下角顯示的紅字。

# src/meowgent/cli/commands.py
...
@cmd_registry(...)
def _change_config(...):	
	...
	
	def _type_validator(...) -> ...:
		...
		
		format_dict = sub_mdoel.model_dump() # 取出內容轉字典

        format_dict[field_name] = input # 將輸入值替換進去
        
        try:
            validated_obj = sub_class.model_validate(format_dict)

            new_value = getattr(validated_obj, field_name)
            # 已經不是字串,而是正確型別了

            return True
        
        except ValidationError as e:
            return f"驗證錯誤 {e.errors()[0]['msg']}"
        
        except Exception as e:
            return f"錯誤 {e}"

為什麼 f"驗證錯誤 {e.errors()[0]['msg']}" 這邊裡面用了「單引號」,如果是 Python 3.12 之前的版本,f"" 時字串檢查只會依照雙引號的位置來判定,所以如果取值的地方鍵用雙引號就會造成「字串提早結束」,但是後面還有東西,就報錯了。
(f'' 時也一樣,裡面要改用雙引號)


存檔

修改的值存入到 new_value 後,還需要做存檔(調用 save_config()):

判斷還外加一個 new_value != now_value 避免值一樣,白忙一場。

# src/meowgent/cli/commands.py
...
@cmd_registry(...)
def _change_config(...):	
	...
	if new_value is not None and new_value != now_value: # 成功更改值

        setattr(sub_mdoel, field_name, new_value) # 更改屬性

        ConfigManager.save_config(config)

應用

還沒結束喔!我們還需要把這些項目給應用上去,最後加上提示:

模型部分已經有更新邏輯,provider 部分於 _change_model() 做更新,system prompt 每次對話自動讀取 config 做更新。

# src/meowgent/cli/commands.py
...
@cmd_registry(...)
def _change_config(...):	
	...
	
	if ...: # 延續前面的 if new_value is not None...
		if cat_name == "models" and field_name == "temperature":
            if hasattr(model_object.provider, "temperature"):
                model_object.provider.temperature = new_value
                
        elif cat_name == "agent":
            if field_name == "max_turns":
                model_object.max_turns = new_value
                
            elif field_name == "tool_approval_mode":
                model_object.tool_approval_mode = new_value
        
        cli.console.print(Padding(
	        f"[green]{desc} 由 {now_value} 更新至 {new_value}[/green]",
	        (0, 0, 0, 2)
        ))

    else:
        cli.console.print(Padding(f"[dim]設定未變更[/dim]", (0, 0, 0, 2)))

哇!搞定了,_change_config() 這函數的內容也真是長啊~寫了差不多 150 行,佔了 commands.py 的一半了。
而經歷了 Day 17 到今天的 Day 20,設定檔相關的功能終於全部完善了,趕緊試試 /config 吧!
而明天要做的事是非常令我期待的「圖片多模態」讀取,馬上我們的 Agent 就可以看照片啦,真是太棒了!


上一篇
Day 19 - 設定檔 - 用指令來更改設定 - 上
系列文
手刻 AI Agent!大一新生的 Python 實戰筆記 共 20 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言