iT邦幫忙

2025 iThome 鐵人賽

DAY 16
0
生成式 AI

阿,又是一個RAG系列 第 16

Day15: 用 llama-index 的 workflow 來把 ReActAgent 兜出來

  • 分享至 

  • xImage
  •  

TL;DR

  • 今天的完整程式碼在 這裡
  • 有一個 ipynb 用來釐清內部的細節 ReAct_parser_and_formatter
    • 具體來說是:
      • ReActChatFormatter
      • ReActOutputParser
  • 程式碼執行結果:
    • 輸出應該是還可以再美化一下
      • 不過就算了,先放過自己

https://ithelp.ithome.com.tw/upload/images/20250930/20177855eSfhUBCant.jpg

Situation

Task

  • 今天就是大名鼎鼎的 ReActAgent 了
  • 這是今天主要參考的範例: Workflow for a ReAct Agent
    • 我們會修正 ChatMemoryBuffer 已經 Deprecated 的問題
    • 自訂一個神秘的加法工具
    • 還有把 llm 的 streaming 改掉,因為我們想要用 gpt-5-mini

Action

  1. 首先是整體 workflow 的 design
  • 我們直接上圖:
    https://ithelp.ithome.com.tw/upload/images/20250930/20177855sTESewAQki.jpg
  • 左邊是 今天的 ReActAgent
  • 右上角是 昨天的 FunctionCallingAgent
  • 可以看到基本上就是在 FunctionCallingAgent 的基礎上
    • 多了 new_user_msg 這個 step
    • 還有 PrepEvent
  • 整體來說就是 4 步
    1. new_user_msg: 管理多次呼叫情況下的 memory
    2. prepare_chat_history: 產生 list of chat_message 讓下一步可以直接 call llm 就好
      • 不管是 上一輪的對話 還是 工具的輸出 還是 工具有錯
    3. handle_llm_input: call 帶有 ReAct prompt 的 llm
      • 就是會 thought 一下,action 一下 observation一下那位
      • 結果有兩種可能
        • 好了回傳給 user
        • 好了幫我 call tool
    4. handle_tool_calls
      • 處理 tool call 的情況,然後把 tool 的 output 回給 prepare_chat_history 讓他準備下一輪
  • 所以要我說的話,ReActAgent 也就是個把長頸鹿放冰箱的事,多了一步把大象拿出來
  1. Event
class PrepEvent(Event):
    pass

class InputEvent(Event):
    input: list[ChatMessage]

class ToolCallEvent(Event):
    tool_calls: list[ToolSelection]

class FunctionOutputEvent(Event):
    output: ToolOutput

class StreamEvent(Event):
    msg: Optional[str] = None
    delta: Optional[str] = None
  • Event 有 5 種
  • 在昨天的基礎上多了 PrepEvent
    • 而且裡面沒有值,就只是送個信號說來開始處理 chat_message 了
  • StreamEvent 完全就是讓我們看中間結果的
    • 如果是用 llm 的 streaming 呼叫要取 delta
    • 如果是直接 chat 就取 msg
    • 都直接是 str
  1. Tools
  • 這是我們今天綁給 llm 的 tool ,就 1 個
    • 你完全可以自訂自己想要的工具
  • 這個工具可以讓我們比較容易的去控制他要不要使用工具
    • 其實本來單純是想說來糊弄他一下...
from typing import Literal
from llama_index.core.tools import FunctionTool

def add(a: Literal[0, 1], b: Literal[0, 1]) -> Literal[0, 1]:
    """這是一個在二元代數結構上定義的神祕「加法」,你可以藉由呼叫這個工具來釐清它的運作邏輯。
    
    此神秘加法滿足以下公理性特徵:
    - 交換律:a ⊕ b = b ⊕ a
    - 結合律:(a ⊕ b) ⊕ c = a ⊕ (b ⊕ c)
    - 冪等律:a ⊕ a = a
    - 加法單位元:0,使得 a ⊕ 0 = a
    - 定義域僅含 0 與 1
    
    Parameters:
        a: either 0 or 1
        b: either 0 or 1
    Returns:
        0 or 1
    """
    if a not in (0, 1) or b not in (0, 1):
        raise ValueError("add is only defined on {0,1}.")
    return 1 if (a == 1 or b == 1) else 0
    
tools = [
    FunctionTool.from_defaults(add)
]
  1. 所以我們正式開始吧 首先是 workflow.init
class ReActAgent(Workflow):
    def __init__(
        self,
        *args: Any,
        llm: LLM | None = None,
        tools: list[BaseTool] | None = None,
        extra_context: str | None = None,
        streaming: bool = False,
        **kwargs: Any,
    ) -> None:
        super().__init__(*args, **kwargs)
        self.tools = tools or []
        self.llm = llm or OpenAI()
        self.formatter = ReActChatFormatter.from_defaults(
            context=extra_context or ""
        )
        self.streaming = streaming
        self.output_parser = ReActOutputParser()
  • 我們需要一個 list of FunctionTool
  • 然後需要一個 llm , 就 llm = OpenAI(model="gpt-5-mini")
    • 我們後面會看到,這個 llm 其實是不需要一定會用 function calling 的
      • 所以 gemma3:12b 是可以的
      • 反而是昨天實作的 FuncationCallingAgent 一定要會用 function calling
      • 當然啦,這個也是看實作,幫不會 function calling 的 llm 兜一個 FuncationCallingAgent 也是完全可以的
  • 然後我們需要 ReActChatFormatter 和 ReActOutputParser
    • 如果你對他們不熟悉是完全沒關係的,我也是第一次看到他們,為此還做了 notebook
    • 總之一個是用來產生 ReAct 的 system prompt (要 tool 資訊)
    • 一個是用來把 Action: xxx parser 成 tool calling 的 (要 function name 跟 argument)
  1. 第一個 step 是 new_user_msg
@step
async def new_user_msg(self, ctx: Context, ev: StartEvent) -> PrepEvent:
    # clear sources
    await ctx.store.set("sources", [])
    # init memory if needed
    memory = await ctx.store.get("memory", default=None)
    if not memory:
        memory = Memory.from_defaults()
    # get user input
    user_input = ev.input
    user_msg = ChatMessage(role="user", content=user_input)
    memory.put(user_msg)
    # clear current reasoning
    await ctx.store.set("current_reasoning", [])
    # set memory
    await ctx.store.set("memory", memory)
    return PrepEvent()
  • 我這邊少一個縮進想說這樣會比較好看
  • 這步是只有新一次的呼叫才會走到的
    • 區分一下:
      • 多次呼叫,是這個 workflow run 多次
      • 多輪對話: run 1 次這個 workflow ,這裡面本身有多輪對話
  • 首先他會清空 sources
    • sources 是一個 list 放每一次的 tool output 的
  • 然後取舊的或是 new 新的 memory
    • 所以這告訴我們多次呼叫的時候其實是看的到上次的問題跟結果的
  • 新的 user input 包成 ChatMessage append 到 memory 裡
    • input 是 StartEvent.input
      • 所以後面呼叫的時候是 w.run(input=query)
    • 和 FunctionCallingAgent 不同的是 他 Memory 只會放 user query 跟 最後的 response
  • 然後清空 current_reasoning
    • current_reasoning 本身是個 list ,裡面放的就是每一步的 thought, action 等等
    • 如上述,每次的呼叫有一個自己的小劇場,下次呼叫這次的小劇場就忘了,只記得結果
  • PrepEvent() 是空的,因為東西都在 ctx 裡了
  1. 第二個 step 是 prepare_chat_history
@step
async def prepare_chat_history(
    self, ctx: Context, ev: PrepEvent
) -> InputEvent:
    # get chat history
    memory = await ctx.store.get("memory")
    chat_history = memory.get()
    current_reasoning = await ctx.store.get(
        "current_reasoning", default=[]
    )
    # format the prompt with react instructions
    llm_input = self.formatter.format(
        self.tools, chat_history, current_reasoning=current_reasoning
    )
    return InputEvent(input=llm_input)
  • input:

    • 這步的 input 是空的,因為主要會用到的 chat_history, current_reasoning 都在 context 裡面
  • output:

    • 這步的 output 是一個 list of chat_message , 就是把雜事處理完回傳一個可以直接丟給 llm 的 input
  • PrepEvent 這個事件有兩個 step 會發出來 (也就是說有兩種情況我們會執行到這個 step)

    • 一個是使用者來新的 query 的時候 從(new_user_msg)
    • 一個是呼叫完工具之後 從(handle_tool_calls)
  • 主要做的事情包含:

    • 從 memory 取得 chat_history
      • 裡面只有 user query
    • 從 ctx 取得 current_reasoning
      • 包含 system prompt , 前一步的 reasoning
    • 然後把 tool 的資訊(放在system prompt)、 chat_history 、 current_reasoning 一起包成一整個 llm_input
      • 是 list of chat message
  • 關於 formatter.format,我們在意三種情況:

      1. call formatter without chat_history and current_reasoning
      • 回傳 [system_prompt]
      1. call formatter with tools and chat_history
      • 回傳 [system_prompt, user_prompt]
      1. call formatter with tool, chat_history and current_reasoning
      • 回傳 [system_prompt, user_prompt, current_reasoning]
      • 順序就是沿路 append 下來,dtype 都是 ChatMessage
    • 詳情可以看 day15_ReActAgent_parser_and_formatter.ipynb
      • system prompt 也在裡面
    • source code: ReActChatFormatter
  1. 第三個 step 是 handle_llm_input
    @step
    async def handle_llm_input(
        self, ctx: Context, ev: InputEvent
    ) -> ToolCallEvent | StopEvent:
        chat_history = ev.input
        current_reasoning = await ctx.store.get(
            "current_reasoning", default=[]
        )
        memory = await ctx.store.get("memory")

        if self.streaming:
            response_gen = await self.llm.astream_chat(chat_history)
            async for response in response_gen:
                ctx.write_event_to_stream(StreamEvent(delta=response.delta or ""))
        else:
            response = await self.llm.achat(chat_history)
            ctx.write_event_to_stream(StreamEvent(msg=response.message.content))

        try:
            reasoning_step = self.output_parser.parse(response.message.content)  # output_parser.parse
            current_reasoning.append(reasoning_step)

            if reasoning_step.is_done:
                memory.put(
                    ChatMessage(
                        role="assistant", content=reasoning_step.response
                    )
                )
                await ctx.store.set("memory", memory)
                await ctx.store.set("current_reasoning", current_reasoning)

                sources = await ctx.store.get("sources", default=[])

                return StopEvent(
                    result={
                        "response": reasoning_step.response,
                        "sources": sources,
                        "reasoning": current_reasoning,
                    }
                )
            elif isinstance(reasoning_step, ActionReasoningStep):
                tool_name = reasoning_step.action
                tool_args = reasoning_step.action_input
                return ToolCallEvent(
                    tool_calls=[
                        ToolSelection(
                            tool_id="fake",
                            tool_name=tool_name,
                            tool_kwargs=tool_args,
                        )
                    ]
                )
        except Exception as e:
            current_reasoning.append(
                ObservationReasoningStep(
                    observation=f"There was an error in parsing my reasoning: {e}"
                )
            )
            await ctx.store.set("current_reasoning", current_reasoning)

        # if no tool calls or final response, iterate again
        return PrepEvent()
  • input: 上一步整理好可以直接丟給 llm 的 list of chat_message

  • output:

    • 這個無論是 function call agent 還是 ReAct Agent 都一樣,他的行動其實要馬下一步是 call 工具,要馬下一步是回傳
  • 主要就 3 件事:

      1. 呼叫 llm
      1. 用 ReActOutputParser 來 parse llm 的 output
      1. 根據 parser 出來的結果不同決定下一步行動,有兩種可能:
      • 發 StopEvent
        • 有結論了,就會回傳 StopEvent
        • 還有一個特殊情況,其實沒有結論,但反正也沒有呼叫工具,所以就回傳了
        • 只有這個情況 memory 才會有更新
      • 發 ToolCallEvent
        • 把 tool_name 跟 tool_kwarg 附上去
        • 這邊一樣是一個 list of tool_calls ,所以是支持一次呼叫多個工具的
  • 呼叫的部分:

    • 跟 FunctionCallingAgent 一樣都是直接丟 chat_history 給 llm,但這邊是用 "achat" 而不是 "achat_with_tools"
    • 主要原因就是 ReAct 被強迫要 Reasoning ,他現在要呼叫工具不能只給 工具名稱 跟 argument , 還要先講說類似 thought: 我需要呼叫工具來回答這個問題
      • 這個其實感覺有點多餘,因為通常也不太會講其他原因
        • 如果在 prompt 強調說 如果要 call 工具的話要詳細說明原因可能有用
        • 然後這樣就有機會遇到大名鼎鼎的"說一套做一套事件",他 Reasoning 說因為某個原因現在要幹麻,然後 Action 給別的
      • 然後就是因為這個 Reasoning 使得我們要多一個 parser 來處理,不能直接用 achat_with_tools
      • 正向看待的話就是支持不會 FunctionCalling 的 LLM
  • ReActOutputParser.parse 的部分,我們在意四種情況:

  1. 最後是 handle_tool_calls
@step
async def handle_tool_calls(
    self, ctx: Context, ev: ToolCallEvent
) -> PrepEvent:
    tool_calls = ev.tool_calls
    tools_by_name = {tool.metadata.get_name(): tool for tool in self.tools}
    current_reasoning = await ctx.store.get(
        "current_reasoning", default=[]
    )
    sources = await ctx.store.get("sources", default=[])
    # call tools -- safely!
    for tool_call in tool_calls:
        tool = tools_by_name.get(tool_call.tool_name)
        if not tool:
            current_reasoning.append(
                ObservationReasoningStep(
                    observation=f"Tool {tool_call.tool_name} does not exist"
                )
            )
            continue
        try:
            tool_output = tool(**tool_call.tool_kwargs)
            sources.append(tool_output)
            current_reasoning.append(
                ObservationReasoningStep(observation=tool_output.content)
            )
        except Exception as e:
            current_reasoning.append(
                ObservationReasoningStep(
                    observation=f"Error calling tool {tool.metadata.get_name()}: {e}"
                )
            )
    # save new state in context
    await ctx.store.set("sources", sources)
    await ctx.store.set("current_reasoning", current_reasoning)
    # prep the next iteration
    return PrepEvent()
  • 這個跟 FunctionCallingAgent 的處理基本上是完全一樣的
    • no_tool: 其實 llm 沒有這個 tool 但是他 call 了 這種會處理
    • tool_error: 工具本身報錯這個會處理
    • observation: 正確呼叫工具之後回傳
  • 唯一不同是以上訊息都是加在 current_reasoning 上
    • 然後成功呼叫的詳情會額外加到 source
    • 這邊都沒動 memory,主要就是不需要跨次呼叫還看的到以前的 reasoning
  1. Call it
async def main():
    # get tool_list
    tool_list = get_tool_list()
    # llm
    llm = OpenAI(model="gpt-5-mini")
    # workflow
    w = ReActAgent(llm=llm, tools=tool_list, timeout=120, verbose=False)
    print('---query1: no tool use')

    query1 = '簡單的問題不要呼叫工具,不用想太多,請問1+1=?'
    print(query1)
    handler = w.run(input=query1)
    async for ev in handler.stream_events(expose_internal=False):
        if isinstance(ev, StopEvent):
            print(ev.result['response'])
        else:
            print(ev.msg)
    print('-----' * 10)
    print('---query2: suggest tool use')

    query2 = '請重新思考一下, 1+1 等於多少,他真的等於 2 嗎? 還是會有其他可能,你有用其他工具查證嗎?'
    print(query2)
    handler = w.run(input=query2)
    async for ev in handler.stream_events(expose_internal=False):
        if isinstance(ev, StopEvent):
            print("====final response")
            print(ev.result['response'])
        else:
            print(ev.msg)
if __name__ == "__main__":
    import asyncio
    asyncio.run(main())
  • 這個主要就是 用 .py 執行的話要用 asyncio.run
  • 然後在 jupyter notebook 上的話直接 run 就可以了
  1. That's it
  • 真正 llama-index 實現 ReAct Agent 的 source code: react_agent.py
    • 不過那又是另一個故事了

Summary

  • 這兩天比較下來,ReActAgent 跟 FunctionCallingAgent 最大的差異就是它強制 LLM 把 reasoning 寫出來,讓我們可以追蹤思路

    • 雖然這也引入了 reasoning 跟 action 行為不同的可能
    • 大概也是這個有思路看起來實在炫炮,所以它超級常被拿來 Demo
  • 我們是以造 Dataset 為核心的系列文

    • 後面就讓我們來比較一下這兩個的準確度吧
  • 第一次接觸到 ReAct 的時候確實有被(它的花俏)吸引,於是就開始調用,然後一開始調用就開始出問題:

    • 支持 tool use 跟 沒支持 tool use 到底差哪阿
    • 到底要怎麼把它的歷史紀錄拿出來阿
    • 怎麼歷史紀錄忽然出現 answer 阿
    • 它 memory 到底留了什麼啊
  • 寫完這篇覺得對 ReActAgent 又有了更深一層的認識,希望讀到這裡的你也可以有所收穫

其他

  • 所以我說,那個 1 + 1 到底是等於幾 ?

Reference:


上一篇
Day14: 用 llama-index 的 workflow 來把 FunctionCallingAgent 寫出來
下一篇
Day16: Pydantic 與 Structured Output
系列文
阿,又是一個RAG20
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言