【langchain】対話の中でOpenAIFunctionsAgentを使う

# はじめに
OpenAIFunctionsAgentに記憶を持たせて使う方法がよくわからなかったので、整理しました。

経緯として、OpenAIFunctionsAgentを使った対話アプリを作りたかったのですが、[langchainのdocs](https://python.langchain.com/docs/modules/agents/agent_types/openai_functions_agent)では、１ターンの実行方法しか書かれておらず、過去の対話履歴をAgentに渡す方法がよくわかりません。

一方、[ConversationalAgent](https://python.langchain.com/docs/modules/agents/agent_types/chat_conversation_agent)という対話用のエージェントもあるのですが、こちらはfunction callingせずに、文字列の応答を力技でパースしています。せっかくならfunction callingのほうが精度が高いはずなので、うまく組み合わせたいです。

そこで、実装例を探すと、langchainの本家が別リポジトリで公開している[chat-langchain](https://github.com/langchain-ai/chat-langchain/blob/master/main.py)という対話アプリで、OpenAIFunctionsCallingが使われていることを発見しました。

# 実装
## get_agent
[chat-langchain/main.py](https://github.com/langchain-ai/chat-langchain/blob/master/main.py)のget_agentという関数を見ていきます。

まず関数の入力と出力を見ます。

```Python
def get_agent(llm, *, chat_history: Optional[list] = None):
    chat_history = chat_history or []
    ...
    return agent_executor
```

入力はllmとchat_historyです。つまり、agentの実行に使う言語モデルと対話履歴です。
出力はagent_executor、つまりAgentExecutorです。Agentそのものではないことに注意です。

chat_historyの初期化の続きから見ていきます。まずpromptのテンプレートを作っています。

```Python
    prompt = OpenAIFunctionsAgent.create_prompt(
        system_message=system_message,
        extra_prompt_messages=[MessagesPlaceholder(variable_name="chat_history")],
    )
```

system_messageのほかに、MessagesPlaceholderを渡しています。
MessagesPlaceholderはMessagePropmtTemplateを継承したクラスで、variableがすでにlistになっているときに使われます。今回は、chat_history変数にあとで、履歴のリストを挿入するのだと思われます。

次にmemoryを定義しています。

```Python
    memory = AgentTokenBufferMemory(
        memory_key="chat_history", llm=llm, max_token_limit=2000
    )

    for msg in chat_history:
        if "question" in msg:
            memory.chat_memory.add_user_message(str(msg.pop("question")))
        if "result" in msg:
            memory.chat_memory.add_ai_message(str(msg.pop("result")))
```

AgentTokenBufferMemoryを初期化したのち、chat_historyの要素をコピーしています。chat_historyの要素はquestionやresultなどのkeyをもつことを想定されているようです。

次にtoolsを取得してAgentを生成しています。

```Python
    tools = get_tools()

    agent = OpenAIFunctionsAgent(llm=llm, tools=tools, prompt=prompt)
```

agentにはllmとtoolsとpromptだけを与えており、memoryは与えていないようです。

最後にAgentExecutorを生成して返しています。

```Python
    agent_executor = AgentExecutor(
        agent=agent,
        tools=tools,
        memory=memory,
        verbose=True,
        return_intermediate_steps=True,
    )

    return agent_executor
```

memoryはAgentExecutorに渡されています。

## chat_endpoint

get_agentで作成されたAgentExecutorが対話アプリでどのように使われているかを見ます。

main.pyのchat_endpointの実装をみます。

```Python
@app.post("/chat")
async def chat_endpoint(request: Request):
    global run_id, feedback_recorded, trace_url
    run_id = None
    trace_url = None
    feedback_recorded = False
    run_collector.traced_runs = []

    data = await request.json()
    question = data.get("message")
    chat_history = data.get("history", [])
    data.get("conversation_id")

    print("Recieved question: ", question)
```

data.get("history")でchat_historyを取得しています。dataはrequestから作られているので、おそらくUI側がチャットの履歴を毎回送るように実装されていると思われます。
get_agentでは、chat_historyはquestionやresultをkeyにもつ辞書のリストであるとして処理されていましたが、そのような辞書になるようにUI側で定義していると思われます。

続きをみます。関数内の関数としてstreamが定義されています。

```
    def stream() -> Generator:
        global run_id, trace_url, feedback_recorded

        q = Queue()
        job_done = object()

        llm = ChatOpenAI(
            model="gpt-3.5-turbo-16k",
            streaming=True,
            temperature=0,
            callbacks=[QueueCallback(q)],
        )
```

ChatOpenAIが定義されています。特に変わったところはありません。
続きを見ます。stream関数内でさらにtaskという関数が定義されています。

```Python
        def task():
            agent = get_agent(llm, chat_history=chat_history)
            agent.invoke(
                {"input": question, "chat_history": chat_history},
                config=runnable_config,
            )
            q.put(job_done)
```

get_agentでagent(agent_executor)を取得した後、agent.invokeが実行されています。invokeは__call__とほぼ同じ役割のメソッドです([参考](https://github.com/langchain-ai/langchain/blob/65e1606daa696e2190fcb410f190c6811f9f8dc3/libs/langchain/langchain/chains/base.py#L59))。invokeにquestionだけでなくchat_historyも渡されている意味はよくわかりません(履歴はAgentExecutorの初期化時に渡し済では？）。
このあとも続きますが、AgentExecutorの実行とは直接的には関係ないので省略します。

## まとめ
get_agentとchat_endpointの処理をまとめると以下のようになります。

- Agentの作成
    - MessagesPlaceholderを使って履歴挿入を可能にしたプロンプトテンプレートを作成し、これを渡してOpenAIFunctionsAgentを初期化する
- AgentExecutorの作成
    - AgentTokenBufferMemory型のmemoryを作成し、chat_historyの内容をコピーする
    - memoryとAgentを与え、AgentExecutorを初期化する
- AgentExecutorの実行
    - AgentExecutor.invokeにinputとchat_historyを与える

chat-langchainではAgentExecutorを応答生成のたびに初期化しています。chat_historyはその都度与えています。
langchainにはChain.runの実行の際にmemoryに履歴を追加する機能もありますが、毎回初期化して都度chat_historyをセットし直しているのには何か理由があるのでしょうか？　おそらくですが、OpenAIFunctionsAgentの場合は、FunctionMessageが対話の都度生成される可能性がありますが、これを履歴には残したくないため、毎回初期化し、user_messageとai_messageだけを取り出している、ような気がします。

# 実験

chat-langchainの実装を参考に対話履歴を考慮したOpenAIFunctionsAgent利用のchainを作ってみます。

```Python
import os
from typing import Optional

from langchain.chat_models import ChatOpenAI
from langchain.prompts import MessagesPlaceholder
from langchain.schema.messages import SystemMessage
from langchain.agents import (
    AgentExecutor,
)
from langchain.agents.openai_functions_agent.base import OpenAIFunctionsAgent
from langchain.agents.openai_functions_agent.agent_token_buffer_memory import (
    AgentTokenBufferMemory,
)
from langchain.agents import load_tools

def get_agent(llm, *, chat_history: Optional[list] = None):
    chat_history = chat_history or []
    system_message = SystemMessage(
        content="あなたは計算を助けるアシスタントです"
    )

    prompt = OpenAIFunctionsAgent.create_prompt(
        system_message=system_message,
        extra_prompt_messages=[MessagesPlaceholder(variable_name="chat_history")],
    )

    memory = AgentTokenBufferMemory(
        memory_key="chat_history", llm=llm, max_token_limit=2000
    )

    for msg in chat_history:
        if "question" in msg:
            memory.chat_memory.add_user_message(str(msg.pop("question")))
        if "result" in msg:
            memory.chat_memory.add_ai_message(str(msg.pop("result")))

    tools = load_tools(["llm-math"], llm=llm)

    agent = OpenAIFunctionsAgent(llm=llm, tools=tools, prompt=prompt)
    agent_executor = AgentExecutor(
        agent=agent,
        tools=tools,
        memory=memory,
        verbose=True,
        return_intermediate_steps=True,
    )

    return agent_executor

def chat(question, chat_history):
    llm = ChatOpenAI(
        model="gpt-3.5-turbo-16k",
        temperature=0
    )
    agent = get_agent(llm, chat_history=chat_history)
    return agent.invoke(
        #{"input": question, "chat_history": chat_history}
        {"input": question}
        )
```

chat関数の末尾で、agent.invokeにchat_historyを与える必要はない気がしたので省いています（これでも動きました。）

計算を助ける対話エージェントになっています。
まず、toolと関係ない入力をしてみます。

```Python
res = chat("こんにちは!", [])
print("\n\n",res)
```

```
> Entering new AgentExecutor chain...
こんにちは！計算を手伝うことができます。どのような計算をお手伝いできますか？

> Finished chain.


 {'input': 'こんにちは!', 'chat_history': [HumanMessage(content='こんにちは!', additional_kwargs={}, example=False), AIMessage(content='こんにちは！計算を手伝うことができます。どのような計算をお手伝いできますか？', additional_kwargs={}, example=False)], 'output': 'こんにちは！計算を手伝うことができます。どのような計算をお手伝いできますか？', 'intermediate_steps': []}
```

OKです。
次にtoolと関係ある入力をしてみます。

```Python
res = chat("1+1は？", [])
print("\n\n",res)
```

```
> Entering new AgentExecutor chain...

Invoking: `Calculator` with `1+1`


Answer: 21+1は2です。

> Finished chain.


 {'input': '1+1は？', 'chat_history': [HumanMessage(content='1+1は？', additional_kwargs={}, example=False), AIMessage(content='', additional_kwargs={'function_call': {'name': 'Calculator', 'arguments': '{\n  "__arg1": "1+1"\n}'}}, example=False), FunctionMessage(content='Answer: 2', additional_kwargs={}, name='Calculator'), AIMessage(content='1+1は2です。', additional_kwargs={}, example=False)], 'output': '1+1は2です。', 'intermediate_steps': [(_FunctionsAgentAction(tool='Calculator', tool_input='1+1', log='\nInvoking: `Calculator` with `1+1`\n\n\n', message_log=[AIMessage(content='', additional_kwargs={'function_call': {'name': 'Calculator', 'arguments': '{\n  "__arg1": "1+1"\n}'}}, example=False)]), 'Answer: 2')]}
```

OKです。
最後に履歴を考慮しないと答えられない質問を与えてみます。
履歴で1+1を尋ねた上で質問で「さらに1足すと？」と聞いているので、回答として3が期待されます。

```Python
res = chat("さらに1足すと？", [{"question": "1+1は？"}, {"result": "2です"}])
print("\n\n",res)
```

```
> Entering new AgentExecutor chain...

Invoking: `Calculator` with `1+1+1`


Answer: 31+1+1は3です。

> Finished chain.


{'input': 'さらに1足すと？', 'chat_history': [HumanMessage(content='1+1は？', additional_kwargs={}, example=False), AIMessage(content='2です', additional_kwargs={}, example=False), HumanMessage(content='さらに1足すと？', additional_kwargs={}, example=False), AIMessage(content='', additional_kwargs={'function_call': {'name': 'Calculator', 'arguments': '{\n  "__arg1": "1+1+1"\n}'}}, example=False), FunctionMessage(content='Answer: 3', additional_kwargs={}, name='Calculator'), AIMessage(content='1+1+1は3です。', additional_kwargs={}, example=False)], 'output': '1+1+1は3です。', 'intermediate_steps': [(_FunctionsAgentAction(tool='Calculator', tool_input='1+1+1', log='\nInvoking: `Calculator` with `1+1+1`\n\n\n', message_log=[AIMessage(content='', additional_kwargs={'function_call': {'name': 'Calculator', 'arguments': '{\n  "__arg1": "1+1+1"\n}'}}, example=False)]), 'Answer: 3')]}
```

正しく3を回答できました。

# おわりに
chat-langchainの実装を参考にできたおかげで、わりとあっさりOpenAIFunctionsAgentを対話履歴を考慮して使うことができました。
これでチャットボット開発が捗りそうです。