iT邦幫忙

2026 iThome 鐵人賽

DAY 12
0

上一篇,說到要來做**「比較複雜的」模型回答的渲染**,那為什麼會複雜呢?
之前有提過關於 Live 渲染會和終端的捲動而出現重複印出的問題,而推理的部分,因為我不想保留推理內容,用了單行輸出render_single_line_streamer())的方式規避了這個問題,
但是,模型最終的回答要被保留下來,所以,我採用的是 Live 先串流出即時內容,當遇到 \n\n 時,也就是換到下一段落時,將內容轉到 Console 輸出。

模型通常在段落結束會輸出「兩個 \n」,而不是只有一個,只有一個 \n 通常是出現在程式碼換行、緊湊的清單(- 或列點)、表格。

而傳入的文字中,實際上要更新的只有最後幾個 token,所以我們要記錄下 Console 目前更新到哪了,Live 只輸出未更新上去的內容

輸出到最後,模型在結束前不會再給兩個 \n,這就造成了一個問題,最後一段會無法固化到 Console 中渲染,所以當一輪對話輸出完後還需要做「清理」的動作,進行固化(Live 的動態顯示固定到靜態顯示的 Console),並把輸出到哪的紀錄給歸零。

還有當工具調用前如果模型有輸出回答也要進行清理。

而對「程式碼區塊」需要額外進行處理,寫程式時,常會用兩個換行來分段,也就是中間隔一行,也就造成了會觸發 Live 轉 Console 的機制,導致本該是同一個區塊的程式碼被分開。

這時還是會有一個問題,我們以段落來為單位固化到 Console,但若一個段落本身就長到已經超過終端的游標位置(例如程式碼就常出現這種問題),我們捲動頁面時還是會有重複顯示的問題存在
這裡我的解法就比較偷懶了(如果各位有什麼更好的解法也可以提供一下 T-T ),在 main.py 建立 Live 物件時,我設定 vertical_overflow="crop",也就是游標超出時 Live 就不繼續渲染了,等模型把這段落全輸出完,再由 Console 渲染出來

大致上的概念就是這樣,事不宜遲,馬上開始實作吧!


初始化

這裡我們要傳入的是 CLIRenderer 類別本身,也就是在 CLIRenderer 裡傳入 self,目的是取用 Console 物件,以及後面會用到的 get_rule()

CLIRenderer 裡傳入 self 後,就可以在 ResponseStreamer 中隨意調用它的方法了,晚點在「初始化 ResponseStreamer 物件」會詳細解釋物件建立流程。

CLIRenderer 是字串的原因是,在 IDE 及 Python 直譯器檢查到 ResponseStreamer 時,它還不存在,會發生「尚未定義」的錯誤。
而如果用字串的話,IDE 會自動在整份檔案中先行尋找

self.last_len 記錄下目前更新到多少個字,self.cache 則是要更新的文字(後續切片切出來的)。

# src/meowgent/cli/renderers.py
from ...
...

class ResponseStreamer: 
    def __init__(self, renderer: "CLIRenderer"):

        self.render = renderer
        self.console = self.render.console

        self.last_len = 0 # 上次更新的長度
        self.cache = ""

class CLIRenderer:
	...

更新 Live 和 Console 的邏輯

先建立負責處理此部分邏輯的函數 update_content,需要傳入完整的對話(會包含之前更新過的內容),以及 Live 物件

# src/meowgent/cli/renderers.py
from ...
...

class ResponseStreamer: 
	...
	
	def update_content(self, full_text: str, live: Live):
	
...

切出新增內容

用此次傳入的長度減掉上次更新的長度(這個長度是不斷被更新的)就會得到此次需要更新的字數 update_len
隨即用傳入的長度做 self.last_len 的更新。

知道哪些部分需要更新後,就可以來做切片了!

起點用「負」update_len 就可以達到「切出最後 update_len 個字」的效果。

這裡切出來的內容,用 += 放入 self.cache

而稍後的邏輯會把 self.cache 區分為要在 Live 的跟要在 Console 的做輸出

# src/meowgent/cli/renderers.py
from ...
...

class ResponseStreamer: 
	...
	
	def update_content(...):

		update_len = len(full_text) - self.last_len # 需要被更新的字數
        self.last_len = len(full_text) # 更新目前字數
        
        self.cache += full_text[-update_len:] if update_len > 0 else ""

...

程式碼區塊判斷

一個完整的程式碼區塊會有成對的(```)組成,我們先來判斷是否成對:

如果有餘數,代表不成對,也就是有程式碼區塊還未閉合

# src/meowgent/cli/renderers.py
from ...
...

class ResponseStreamer: 
	...
	
	def update_content(...):
		...
		
		done_code_block = (self.cache.count("```") % 2 == 0) # True -> 程式碼區塊完成

...

Console 更新判斷

接著我們要來看看哪些內容要固化到 Console 裡

固化要符合以下三個條件:「程式碼區塊標籤成對(已完成)」、「內容包含換段落」、「換段落在程式碼標籤後(防止程式碼內的)」。

最後一個條件是在防止「此段傳入的內容結尾在程式碼區塊結束的標籤」,也就是 ``` 後面沒有換段落,可能會導致程式碼區塊內的「隔行」被判定為斷點。

條件通過後,首先先把上一次更新到 Live 上的內容給清除,
接著來切出要固化的內容,直接拿 last_line_idx 當終點切,並直接 print() 印出,最後更新 self.cache 的內容。

兩個 \n 不會被切入 Console 的列印。

self.cache 更新部分要加 2 是因為我們必須把開頭的兩個 \n 給切掉,否則下一輪沒有出現兩個 \n 的話 last_line_idx = self.cache.rfind("\n\n") 會找到開頭的那兩個,出現錯誤。
那這時就有另外一個問題了,兩段之間的空行(兩個 \n )沒了,所以需要設定 Padding(..., (0, 0, 1, 2)),也就是下方空一格

# src/meowgent/cli/renderers.py
from ...
...

class ResponseStreamer: 
	...
	
	def update_content(...):
		...
		
		last_tag_idx = self.cache.rfind("```")
        last_line_idx = self.cache.rfind("\n\n")
		
		if done_code_block and last_line_idx != -1 and last_line_idx > last_tag_idx:

            live.update("") # 把上一次的 live 更新刪掉

            self.console.print(Padding(
		        Markdown(self.cache[:last_line_idx]),
		        (0, 0, 1, 2)) 
            ))

            self.cache = self.cache[last_line_idx + 2:] 
...

更新 Live

我們先判斷 self.cache,以防裡面已經沒東西了,通過檢查後就可以輸出到 Live 了,而這裡為了明確區分出回答,我給上方空了一行

# src/meowgent/cli/renderers.py
from ...
...

class ResponseStreamer: 
	...
	
	def update_content(...):
		...
		
		if self.cache.strip():
		# 還有未完成的內容(下一行),包含有成對程式碼全在此更新
		
            live.update(
                Group(
	                # 上方空一行,左邊空兩格
                    Padding(Markdown(self.cache), (1, 0, 0, 2)), 
                    self.render.get_rule()
                )
            )   
...

清理

有兩種清理狀況:

  1. 有時模型在調用工具前會說一些話例如「這裡幫你檢查文件」這樣的話,接著去調用工具再開始回答。而這時,需要把「這裡幫你檢查文件」固化到 Console(因為通常這裡不會伴隨兩個 \n),並且把 self.cache 以及 self.last_len 歸零,否則調用完工具的回答就會在「這裡幫你檢查文件」的基礎上進行切片。
  2. 第二種情況是當整段對話已經輸出完成了,同樣要對 self.cache 以及 self.last_len 歸零。並且也要把還未固化到 Console 的內容做固化,不同的是文字結束了所以要加上分隔線
# src/meowgent/cli/renderers.py
from ...
...

class ResponseStreamer: 
	...
	
	 def reset(self, live: Live):
        
        if self.cache.strip():
           live.update("")
           self.console.print(Padding(Markdown(self.cache), (0, 0, 0, 2))) 

        self.cache = ""
        self.last_len = 0
        
    def clean(self, live: Live):

        if self.cache.strip():
            live.update("")
            self.console.print(
                Group(
                    Padding(Markdown(self.cache), (0, 0, 0, 2)),
                    self.render.get_rule()
                )
            )

        self.cache = ""
        self.last_len = 0

初始化 ResponseStreamer 物件

跟前面說的一樣,我們要傳入 self

# src/meowgent/cli/renderers.py

class ResponseStreamer: 
	...
	
class CLIRenderer:
	...
	
	def get_response_streamer(self) -> ResponseStreamer:
        return ResponseStreamer(renderer=self)
  1. 首先我們在 main.pycli = CLIRenderer() 建立了 CLIRenderer 物件(跑了 CLIRenderer()__init__,也就是 Console 物件被建立了)。
  2. 接著,同樣在 main.pyresponse_streamer = cli.get_response_streamer(),建立了 ResponseStreamer 物件,而因為把 CLIRenderer()self 傳入了,所以 ResponseStreamer() 就擁有了同一個 Console 物件以及 CLIRenderer() 內的方法。

如此一來所有有關渲染的部分都完成啦!距離可以完整使用這個 CLI 介面只差「ollama 進程管理」跟「主程式」兩部分了。
下一篇,來完成前者~


上一篇
Day 11 - 漂亮的 CLI 介面 - 渲染 - 上
系列文
手刻 AI Agent!大一新生的 Python 實戰筆記12
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言