上一篇,說到要來做**「比較複雜的」模型回答的渲染**,那為什麼會複雜呢?
之前有提過關於 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:
...
先建立負責處理此部分邏輯的函數 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 裡:
固化要符合以下三個條件:「程式碼區塊標籤成對(已完成)」、「內容包含換段落」、「換段落在程式碼標籤後(防止程式碼內的)」。
最後一個條件是在防止「此段傳入的內容結尾在程式碼區塊結束的標籤」,也就是
```後面沒有換段落,可能會導致程式碼區塊內的「隔行」被判定為斷點。
條件通過後,首先先把上一次更新到 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:]
...
我們先判斷 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()
)
)
...
有兩種清理狀況:
\n),並且把 self.cache 以及 self.last_len 歸零,否則調用完工具的回答就會在「這裡幫你檢查文件」的基礎上進行切片。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)
- 首先我們在
main.py中cli = CLIRenderer()建立了 CLIRenderer 物件(跑了CLIRenderer()的__init__,也就是 Console 物件被建立了)。- 接著,同樣在
main.py中response_streamer = cli.get_response_streamer(),建立了 ResponseStreamer 物件,而因為把CLIRenderer()的self傳入了,所以ResponseStreamer()就擁有了同一個 Console 物件以及CLIRenderer()內的方法。
如此一來所有有關渲染的部分都完成啦!距離可以完整使用這個 CLI 介面只差「ollama 進程管理」跟「主程式」兩部分了。
下一篇,來完成前者~