atomz technology logoatomz technologyBlog
記事一覧

会話が続くレビューBotをDifyで作る ― 「毎回フルレビュー問題」の抜け出し方

·11 分で読めます
katsunari-nagai-80katsunari-nagai-80

はじめに

私は普段、企業向けにAIを使った業務改善のお手伝いをしています。 その中で本当によくいただくのが、こういう相談です。

  • 「会議資料を、過去の議事録に照らしてレビューしてほしい」
  • 「計画書を、過去の計画書と見比べて問題ないかチェックしてほしい」

要するに「過去のナレッジを根拠に、新しいドキュメントをレビューしてくれるBot」ですね。 このテーマは需要がとにかく多くて、私もこの手のレビューBotを何度も作ってきました。

ところが、何も考えずに素朴に作ると、わりとすぐ壁にぶつかります。 2回目以降の発話でも、毎回フルレビューが走ってしまうのです。

最初にこれを踏んだときは「なんで深掘りの質問にまでレビューし直すんだ…」としばらく悩みました。 原因が分かってみれば単純なのですが、設計の発想そのものを変える必要がありました。

この記事では、その壁の正体と、抜け出すための設計の勘所を書いていきます。 題材は社内で実際に作ったレビューBotを一般化したもので、内容は架空のものに置き換えています。

対象読者

  • Difyでチャットボット(Chatflow)を作ったことがある方
  • 「1回答えて終わり」ではなく、会話が続くBotを作りたい方
  • レビュー・要約・診断など、最初に重い処理をするBotを設計している方

作りたかったもの

イメージしていたのは、こんなBotです。

  • ユーザーがレビューしてほしいPDFをアップロードする
  • Botが過去のナレッジ(過去事例や指摘集をRAGで検索)を根拠にレビューを返す
  • ユーザーはそのレビューに対して、会話で深掘りしたり、別の観点で過去事例を探し直したりできる

ポイントは「一発レビューして終わり」ではなく、「レビューを起点に対話が続く」こと。 ここを実現したかったわけです。

Difyで会話を扱うので、作るのは Chatflow(advanced-chat) になります。

素朴に作るとぶつかる壁

最初に組んだのは、ごく普通の一直線のワークフローでした。

図を描画中…

レビュー生成のプロンプトには、当然こう書きますよね。

あなたはドキュメントのレビュアーです。
以下の観点でレビューしてください:
- 不足している記載
- 過去事例に照らしたリスク
- 修正すべきポイント
...
出力は次の構造化フォーマットで返してください: { ... }

これ、初回レビューとしては完璧に動きます。 問題が出るのは2回目以降でした。

Chatflowは、ユーザーが追加で発話するたびに、同じワークフローを頭から再実行します。 その結果どうなるかというと、

  • ユーザー「さっきの3番目の指摘、もう少し詳しく教えて」
  • Bot → またPDFを解析して、また検索して、また同じ観点でフルレビューを生成して返す

こうなります。 「ちょっと深掘りしたいだけ」なのに、毎回ゼロからレビューし直されるわけです。

つまり、レビュー観点をプロンプトに固定した結果、Botは「レビューすること」しかできない体になってしまったのですね。 深掘りの質問にも雑談にも、全部フルレビューで殴り返してくる。これが最初の壁でした。

レビュー観点をプロンプトに書く設計は、本来「1ターン目だけの振る舞い」を全ターンに適用してしまう。会話型にするには、ターンごとに振る舞いを変える仕組みが要る。

設計の発想を変える ― 3つの道具

ここで考え方を切り替えました。 会話型のレビューBotを成立させるのに必要だったのは、次の3つです。

要素Difyの機能役割
状態会話変数(Conversation Variables)「もうレビュー済みか?」「前回のレビュー内容は?」を会話をまたいで保持する
分岐質問分類器(Question Classifier)/ IF-ELSEユーザーの発話意図で「初回フルレビュー / 深掘り / 再検索」を振り分ける
文脈メモリ(会話履歴)LLMノードに会話履歴を渡して、自然な対話の流れを保つ

この3つを組み合わせると、フローはこう変わります。

図を描画中…

「初回フルレビュー」と「会話モード」を会話変数のフラグで分けて、会話モードの中をさらに質問分類器で割る。 ざっくり言うとこれが全体像です。

ここから先は、3つの道具をそれぞれどう使ったかを順番に書いていきます。

勘所① 状態を「会話変数」に畳む

Chatflowは毎ターン頭から再実行されるので、前のターンで何が起きたかをどこかに残しておかないと、そもそも会話が成り立ちません。 その「どこか」が会話変数です。

今回使った会話変数を一般化すると、こんな感じになります。

conversation_variables:
  - name: is_reviewed       # "1" ならフルレビュー済み(初回/追問の分岐に使う)
    value_type: string
  - name: doc_overview      # 文書の概要(JSON文字列で保持)
    value_type: string
  - name: initial_review    # 初回フルレビューの本文(深掘りの文脈に使う)
    value_type: string
  - name: doc_text          # 文書本文(再検索なしの深掘りで参照)
    value_type: string

ワークフローの入口にIF-ELSEを置いて、is_reviewed が空かどうかで初回/追問を分けます。

  • 初回:フルレビューを実行して、最後に Assigner(変数代入)ノードis_reviewed="1"initial_review(生成したレビュー本文)、doc_overview(解析結果)を保存する。
  • 2回目以降is_reviewed="1" なので会話モードへ。保存済みの initial_reviewdoc_overview を文脈として使い回す。

ここが今回の肝でした。 「レビュー観点を毎回プロンプトに書いてレビューさせる」のではなく、「初回レビューの結果を会話変数に畳んでおいて、2回目以降はそれを参照して会話する」。 この発想に切り替えた瞬間に、Botが「レビュー結果について話せる」ようになりました。

勘所② 質問分類器で「ターンの役割」を分ける

会話モードに入ったあと、ユーザーの発話をよく観察すると、だいたい2種類に分かれていました。

  1. 再検索したい:「別の現場の事例は?」「夜間作業のケースで探して」のように、新しい条件でナレッジを引き直したいもの。
  2. 深掘りしたい:「さっきの2番目の指摘を詳しく」「要約して」のように、すでに出したレビュー内容について話すもの。RAGの再検索は要らない。

この振り分けに使ったのが 質問分類器(Question Classifier)ノード です。 Dify標準のノードで、自然文のクラス(カテゴリ)を定義しておくと、LLMが発話をそのどれかに分類してくれます。 出力されたクラスごとに後続を分岐できるので、IF-ELSEを手書きするより意図分類には向いています。

図を描画中…

実際の設定はこんな感じ

質問分類器ノードに設定したのは、ざっくり次の3つです。

  • 入力変数:sys.query(ユーザーの発話)
  • クラス(カテゴリ):下の3つ
  • 「指示」欄(任意):迷ったときの倒し方を一文だけ書く

クラスは、名前を短く、説明文に具体例を必ず添えるのがコツでした。 私が実際に入れた文面はこちらです(そのままコピペして調整できるようにしておきます)。

クラス名説明文(具体例つきで書く)
再検索新しい条件・場所・対象・期間で過去事例を探し直したい質問。例:「別の駅の事例は?」「夜間作業のケースで探して」「もっと新しい事例ある?」
深掘りすでに出したレビュー内容そのものについて尋ねる質問。新しい検索は不要。例:「さっきの2番目の指摘を詳しく」「その理由は?」「要約して」
その他上のどちらにも当てはまらない雑談・あいさつ・使い方の質問。例:「ありがとう」「使い方を教えて」

実際のノード設定画面は次のようになります。

Dify の質問分類器ノードの設定画面。入力変数に sys.query、クラスとして「再検索」「深掘り」「その他」を具体例つきで定義している。
図: 質問分類器ノードの設定例(クラス名+具体例つきの説明文)

質問分類器を使ってみて分かった勘所はこのあたりです。

  • クラスは「後続の処理が変わる単位」で切る。「再検索が必要か/不要か」のように、分岐先がはっきり違うところでクラスを定義する。気持ちで細かく分けすぎると、分類がブレます。
  • クラスの説明文には具体例を添える。上の表のように、説明文に「例:〜」を入れるだけで分類精度がはっきり上がりました。

勘所③ メモリ(会話履歴)を忘れずにONにする

あと一歩、会話を自然にするのに効くのが メモリ(会話履歴) です。

Difyのメモリは、LLMノードに「直近のユーザー発話とBot応答のやりとり」を自動で文脈として渡してくれる仕組みです。 これがあると、「さっき言ったやつ」「その2番目」のような前のターンを前提にした言い回しを、LLMがそのまま解決してくれます。

注意:DifyのLLMノードはデフォルトでメモリがOFF

ここが地味にハマるポイントでした。 DifyのLLMノードは、メモリの設定が初期状態でOFFになっています。

そのため、何も設定しないまま追問の回答ノードを作ると、LLMは毎回「今このターンの発話」しか見ていない状態になります。 すると「さっきの2番目を詳しく」と言われても「さっき」が分からず、急にトンチンカンな受け答えを始める。 深掘り回答のLLMノードでは、メモリのトグルをONにし忘れないことが大事です。

LLMノードの設定パネルにある「メモリ」のトグルがそれです。

Dify の LLM ノードの設定パネル。「メモリ」のトグルが表示されており、初期状態では OFF になっている。
図: LLM ノードの「メモリ」トグル。デフォルトは OFF なので、追問の回答ノードでは ON にする

メモリウィンドウのサイズで「どこまで覚えるか」を決める

メモリをONにすると、もうひとつ「ウィンドウのサイズ」という設定が出てきます。 これは、直近の何ターン分のやりとりを文脈として渡すかを決める数字です。

ここをどう設定するかでBotの挙動が変わります。

  • 小さすぎる:少し前のやりとりがすぐ文脈から抜けてしまい、「さっきの話」が解決できなくなる。
  • 大きすぎる:毎ターン渡すトークンが増えて、コストとレイテンシがかさむ。古い話に引っ張られて回答がブレることもある。

レビューBotの深掘り回答では、直近の数ターンを覚えていれば十分なケースが多かったので、ウィンドウは欲張りすぎず、数ターン程度に抑えるのがバランス良かったです。 そもそも「絶対に覚えていてほしい事実」は会話変数に畳んでいるので(勘所①)、メモリ側で長い履歴を抱え込む必要はありません。

メモリだけに頼らない ― 履歴には限界がある

ただし、メモリは万能ではありません。 会話履歴は会話が長くなるほど膨らんでいき、古いやりとりはウィンドウから溢れて落ちていきます。 つまり、初回レビューの結論のような「絶対に参照したい事実」を履歴頼みにすると、会話が続くうちにスッと消えることがあります。

なので使い分けはシンプルにこう考えました。

  • 絶対に参照したい中核情報(初回レビュー本文、文書概要)→ 勘所①の 会話変数 に明示的に畳んでおく
  • 会話の自然な流れ(「さっき言ったやつ」の解決など)→ メモリ(会話履歴) に任せる

ひとことでまとめると、「事実は会話変数、流れはメモリ」。 メモリはONにする、でも大事な事実はメモリに預けっぱなしにしない。これがちょうどいいバランスでした。

まとめ

会話型レビューBotのキモは、結局このひとことに尽きます。

「レビュー観点をプロンプトに固定する」発想から、「初回の結論を状態として畳んで、2回目以降は意図で振り分けて会話する」発想へ切り替えること。

最後に、まとめると下記になります。

  • 初回/追問を 会話変数のフラグ で分けたか
  • 追問の中を 質問分類器(or 構造化出力) で「再検索/深掘り」に割ったか
  • 追問で必要な前提を 初回のうちに会話変数へ保存したか(前ノード直結に頼らない)
  • 事実は会話変数・流れはメモリ で役割分担できているか

この型はレビューBotに限らず、「最初に重い処理をして、以降はその結果について対話する」系のチャットボット全般(要約Bot、診断Bot、申請チェックBotなど)に応用できます。 似たようなBotで詰まっている方の、何かのヒントになれば嬉しいです。


付録:主要ノード構成(一般化)

ノード種類役割
開始startPDF/発話の受け取り
初回判定if-else会話変数 is_reviewed の空判定で初回/追問を分岐
ドキュメント解析llm(構造化出力)概要抽出
ナレッジ検索knowledge-retrieval過去事例をRAG検索
フルレビューllm(構造化出力)観点別レビューを生成(初回専用)
会話変数保存assignerフラグ・レビュー本文・概要を保存
質問分類器question-classifier追問の意図を「再検索/深掘り」に分類
会話回答llm(メモリON)保存済み文脈+(必要なら)再検索結果で対話的に回答

Services

その他のサービスはatomz technology ↗で。