iT邦幫忙

2026 iThome 鐵人賽

DAY 14
0
AI Engineering

地端 AI 建築學系列 第 14

14 LlamaIndex (3) 回應與串流

  • 分享至 

  • xImage
  •  

承接前兩篇的 LlamaIndex 基礎,本篇聚焦在「檢索完成之後」的兩件事。


Query 後 LlamaIndex 到底做了什麼?

一個 query_engine.query(...) 呼叫內部大致是這樣的流程:

使用者問題
   │
   ▼
Retriever  ──►  取出 top-k 相關的 Node(文字片段)
   │
   ▼
Node Postprocessor(可選)──►  過濾、重排序節點
   │
   ▼
Response Synthesizer  ──►  把節點餵給 LLM,組裝成最終答案 (← 本節重點)
   │
   ▼
回傳 Response(或 Streaming Response)

Response Mode講的就是最後一步:Response Synthesizer 該用什麼策略,把檢索到的多個文字片段餵給 LLM,並組成一個答案。這件事在地端場景特別重要,因為它直接決定了:

  • 這次查詢會呼叫幾次 LLM(地端 9B 模型每次生成都有實質延遲與顯卡負載)

  • 每次呼叫要塞多少上下文(受限於 context window)

  • 最終答案的詳細程度 vs. 回應速度如何取捨


七種 Response Mode 整理

Mode 運作方式 LLM 呼叫次數 地端適用情境
compact(預設) 先把能塞進 context window 的節點盡量「壓縮串接」成一個 prompt,塞不下才切下一輪,再用 refine 方式接續 較少 地端首選。9B 模型 context window 有限,能省一次是一次
refine 逐一節點跑:先用第一個節點回答,再把「前一輪答案 + 下一個節點」丟給模型精修(refine),依序做完所有節點 每個節點一次(最多) 需要非常仔細、逐段消化的長文摘要/精修,但地端會明顯變慢,較少用
tree_summarize 把節點分批串接、各自送 LLM 得到多個「子答案」,再把子答案當成新的一批節點遞迴往上彙總,直到剩一個答案 呈樹狀遞迴,通常多於 compact 適合「大量文件做摘要」這類任務;因為每一層彼此獨立,理論上可以平行送給地端模型(但要注意單張顯卡的併發上限)
simple_summarize 直接把所有節點文字截斷(truncate)到塞進單一 prompt,只呼叫一次 LLM 1 次 需要極快回應、可接受漏掉部份細節時的地端快速摘要
no_text 只跑 Retriever,不呼叫 LLM,回傳的 response.source_nodes 可直接檢視命中的節點 0 次 地端資源寶貴,可用於「先確認有沒有命中相關文件」再決定要不要真的送給 LLM 生成,或用來做 Debug/檢索品質評估
accumulate 對每個節點各自套用同一個 query,把每次的回答獨立收集成陣列,最後串接成一份結果 每個節點一次 需要「對每段文件分別回答同一問題」的情境,例如逐份文件抽取欄位
compact_accumulate accumulate 相同,但送出前先做跟 compact 一樣的壓縮串接,減少呼叫次數 accumulate accumulate 的地端省資源版本

設定方式

query_engine = index.as_query_engine(
    response_mode="compact",   # 也可以是 "tree_summarize" / "no_text" / "accumulate" ...
    similarity_top_k=5,
)

response = query_engine.query("公司的請假流程是什麼?")
print(response)

若你是用低階 API(自己組 Retriever + Response Synthesizer),則是在 get_response_synthesizer 上指定:

from llama_index.core import get_response_synthesizer
from llama_index.core.query_engine import RetrieverQueryEngine

synthesizer = get_response_synthesizer(response_mode="tree_summarize")
query_engine = RetrieverQueryEngine(
    retriever=retriever,
    response_synthesizer=synthesizer,
)

地端場景的實務建議

  1. 預設就用 compact:在 9B 這個等級的地端模型上,refine 模式一旦命中 5、6 個節點,等於連續發動好幾次生成,實測延遲會非常明顯,compact 幾乎都能取代它、且品質差異有限。

  2. no_text 再決定要不要生成:如果你的應用場景常常「問了半天結果知識庫根本沒有相關資料」,可以先用 no_text 檢查 source_nodes 的相似度分數,分數太低就直接回覆「查無相關資料」,不必浪費一次地端 LLM 生成。

  3. 大量摘要任務用 tree_summarize,但注意平行度:地端如果只有一張顯卡在跑 ornith-1.5-9btree_summarize 理論上可平行的子任務,實際上還是會被 GPU 排隊序列化,效益會打折扣,記得評估。

  4. simple_summarize 是犧牲品質換速度的手段:truncate 會直接丟掉塞不下的內容,只在「使用者要的是快速抓重點」而非精確答案時使用。


非同步 Streaming:把 token 即時吐給用戶端

為什麼地端場景特別需要 Streaming

雲端大模型(如 GPT-4 等級)生成速度快,使用者等個一兩秒還能接受;但地端 9B 模型受限於硬體,完整生成一段答案可能要好幾秒甚至十幾秒。如果讓使用者盯著空白畫面等到最後才一次顯示,體驗會很差。Streaming 讓我們一收到第一個 token 就能往前端送,大幅降低「感受到的延遲」(perceived latency),即使總生成時間沒有變快。

基本用法(同步)

官方文件的作法是在建立 query engine 時打開 streaming=True

query_engine = index.as_query_engine(streaming=True, similarity_top_k=5)

streaming_response = query_engine.query("公司的請假流程是什麼?")

for text in streaming_response.response_gen:
    print(text, end="", flush=True)

有幾個重點:

  • 回傳的物件是 StreamingResponsequery() 呼叫本身在 LLM 開始生成時就會回傳(不是等生成完),實際文字要透過 response_gen 這個 generator 逐段取得。

  • 若 query engine 內部需要多次呼叫 LLM(例如 refine 模式命中多個節點),只有最後一次 LLM 呼叫會被 streaming,前面的呼叫還是同步跑完。這也是另一個建議地端優先用 compact 的理由:呼叫次數少,代表使用者等待「開始 streaming」之前的空窗期也比較短。

  • 要能 streaming,LLM 的整合必須支援串流輸出。Ollama 的 LLM 整合本身有支援 stream_complete / stream_chat,所以 ornith-1.5-9b 透過 Ollama 起服務時可以正常拿到逐 token 的輸出;如果換成不支援串流的 LLM 整合,呼叫時會丟出 NotImplementedError,建議先用小段文字測試一次再上線。

非同步版本:aquery + async_response_gen

同步的 for text in streaming_response.response_gen 會阻塞整個 event loop,不適合放進像 FastAPI 這種非同步 Web 服務裡(會卡住其他請求)。正確做法是改用非同步查詢 aquery,並用 async for 搭配 async_response_gen()

query_engine = index.as_query_engine(streaming=True, similarity_top_k=5)

streaming_response = await query_engine.aquery("公司的請假流程是什麼?")

async for text in streaming_response.async_response_gen():
    # 這裡就可以直接 yield 給前端,而不會卡住整個服務
    print(text, end="", flush=True)

備註async_response_gen() 是否可用、行為是否與 response_gen 完全對稱,會隨 llama-index-core 版本略有調整,正式上線前建議針對你安裝的版本跑一次最小範例驗證。若某個版本沒有提供,退而求其次的作法是把同步的 response_gen 丟進 run_in_executor 這類的執行緒池,避免阻塞主 event loop。

串接 FastAPI:用 SSE 把 token 吐給前端

把上面的非同步 generator 包進 FastAPI 的 StreamingResponse,就能做出一個「邊生成邊顯示」的地端 RAG API:

from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from pydantic import BaseModel

app = FastAPI()

class QueryRequest(BaseModel):
    question: str

@app.post("/query")
async def query_endpoint(req: QueryRequest):
    query_engine = index.as_query_engine(streaming=True, similarity_top_k=5)
    streaming_response = await query_engine.aquery(req.question)

    async def event_generator():
        async for token in streaming_response.async_response_gen():
            # SSE 格式:每則訊息用 "data: ..." 開頭,並以空白行結尾
            yield f"data: {token}\n\n"
        yield "data: [DONE]\n\n"

    return StreamingResponse(event_generator(), media_type="text/event-stream")

前端只要用 EventSource(原生瀏覽器 API)或任何支援 SSE 的 fetch 套件監聽這個端點,就能一段一段把文字接上畫面,體驗跟 ChatGPT 網頁版的「打字機效果」一致。

地端部署時的併發注意事項

跟雲端 API 不同,地端服務背後就是你自己那一台(或那幾張)顯卡在跑 ornith-1.5-9b,這裡有幾個實務上要處理的坑:

  1. 模型服務本身的併發上限:Ollama 預設同時能處理的生成請求數是有限的(受顯存與設定影響),FastAPI 端就算開了非同步,多個使用者同時發問時,實際生成還是會在 Ollama 那端排隊。可以在 FastAPI 這層用 asyncio.Semaphore 主動限制同時進入生成階段的請求數,避免大量請求把服務打到 OOM 或逾時。

  2. 連線中斷處理:使用者關掉分頁或提前中止請求時,記得讓 generator 能被正常 cancel(FastAPI/Starlette 預設會處理 asyncio.CancelledError),避免地端 GPU 資源被「孤兒生成」佔用。

  3. 逾時設定要放寬:地端 9B 模型的 Time To First Token 可能比雲端 API 慢,Ollama(...)request_timeout 建議抓寬一點(例如 120–180 秒),避免長文檢索 + 生成還沒完成就被判定逾時。


小結

這一篇把 LlamaIndex query engine 檢索完成之後的兩件事講完了:

  • Response Modes 決定了「LLM 要被呼叫幾次、怎麼消化檢索到的節點」,在地端 9B 模型的資源限制下,compact 是最實用的預設值,no_text 則是省算力的好幫手。

  • Streaming(特別是非同步版本)是地端服務體感速度的關鍵,透過 aquery + async_response_gen() 搭配 FastAPI 的 StreamingResponse,可以做出跟雲端服務一樣流暢的打字機效果。


上一篇
13 LlamaIndex (2) 檢索後處理
系列文
地端 AI 建築學14
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言