はじめに
私は普段、企業向けに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_reviewやdoc_overviewを文脈として使い回す。
ここが今回の肝でした。 「レビュー観点を毎回プロンプトに書いてレビューさせる」のではなく、「初回レビューの結果を会話変数に畳んでおいて、2回目以降はそれを参照して会話する」。 この発想に切り替えた瞬間に、Botが「レビュー結果について話せる」ようになりました。
勘所② 質問分類器で「ターンの役割」を分ける
会話モードに入ったあと、ユーザーの発話をよく観察すると、だいたい2種類に分かれていました。
- 再検索したい:「別の現場の事例は?」「夜間作業のケースで探して」のように、新しい条件でナレッジを引き直したいもの。
- 深掘りしたい:「さっきの2番目の指摘を詳しく」「要約して」のように、すでに出したレビュー内容について話すもの。RAGの再検索は要らない。
この振り分けに使ったのが 質問分類器(Question Classifier)ノード です。 Dify標準のノードで、自然文のクラス(カテゴリ)を定義しておくと、LLMが発話をそのどれかに分類してくれます。 出力されたクラスごとに後続を分岐できるので、IF-ELSEを手書きするより意図分類には向いています。
実際の設定はこんな感じ
質問分類器ノードに設定したのは、ざっくり次の3つです。
- 入力変数:sys.query(ユーザーの発話)
- クラス(カテゴリ):下の3つ
- 「指示」欄(任意):迷ったときの倒し方を一文だけ書く
クラスは、名前を短く、説明文に具体例を必ず添えるのがコツでした。 私が実際に入れた文面はこちらです(そのままコピペして調整できるようにしておきます)。
| クラス名 | 説明文(具体例つきで書く) |
|---|---|
| 再検索 | 新しい条件・場所・対象・期間で過去事例を探し直したい質問。例:「別の駅の事例は?」「夜間作業のケースで探して」「もっと新しい事例ある?」 |
| 深掘り | すでに出したレビュー内容そのものについて尋ねる質問。新しい検索は不要。例:「さっきの2番目の指摘を詳しく」「その理由は?」「要約して」 |
| その他 | 上のどちらにも当てはまらない雑談・あいさつ・使い方の質問。例:「ありがとう」「使い方を教えて」 |
実際のノード設定画面は次のようになります。

質問分類器を使ってみて分かった勘所はこのあたりです。
- クラスは「後続の処理が変わる単位」で切る。「再検索が必要か/不要か」のように、分岐先がはっきり違うところでクラスを定義する。気持ちで細かく分けすぎると、分類がブレます。
- クラスの説明文には具体例を添える。上の表のように、説明文に「例:〜」を入れるだけで分類精度がはっきり上がりました。
勘所③ メモリ(会話履歴)を忘れずにONにする
あと一歩、会話を自然にするのに効くのが メモリ(会話履歴) です。
Difyのメモリは、LLMノードに「直近のユーザー発話とBot応答のやりとり」を自動で文脈として渡してくれる仕組みです。 これがあると、「さっき言ったやつ」「その2番目」のような前のターンを前提にした言い回しを、LLMがそのまま解決してくれます。
注意:DifyのLLMノードはデフォルトでメモリがOFF
ここが地味にハマるポイントでした。 DifyのLLMノードは、メモリの設定が初期状態でOFFになっています。
そのため、何も設定しないまま追問の回答ノードを作ると、LLMは毎回「今このターンの発話」しか見ていない状態になります。 すると「さっきの2番目を詳しく」と言われても「さっき」が分からず、急にトンチンカンな受け答えを始める。 深掘り回答のLLMノードでは、メモリのトグルをONにし忘れないことが大事です。
LLMノードの設定パネルにある「メモリ」のトグルがそれです。

メモリウィンドウのサイズで「どこまで覚えるか」を決める
メモリをONにすると、もうひとつ「ウィンドウのサイズ」という設定が出てきます。 これは、直近の何ターン分のやりとりを文脈として渡すかを決める数字です。
ここをどう設定するかでBotの挙動が変わります。
- 小さすぎる:少し前のやりとりがすぐ文脈から抜けてしまい、「さっきの話」が解決できなくなる。
- 大きすぎる:毎ターン渡すトークンが増えて、コストとレイテンシがかさむ。古い話に引っ張られて回答がブレることもある。
レビューBotの深掘り回答では、直近の数ターンを覚えていれば十分なケースが多かったので、ウィンドウは欲張りすぎず、数ターン程度に抑えるのがバランス良かったです。 そもそも「絶対に覚えていてほしい事実」は会話変数に畳んでいるので(勘所①)、メモリ側で長い履歴を抱え込む必要はありません。
メモリだけに頼らない ― 履歴には限界がある
ただし、メモリは万能ではありません。 会話履歴は会話が長くなるほど膨らんでいき、古いやりとりはウィンドウから溢れて落ちていきます。 つまり、初回レビューの結論のような「絶対に参照したい事実」を履歴頼みにすると、会話が続くうちにスッと消えることがあります。
なので使い分けはシンプルにこう考えました。
- 絶対に参照したい中核情報(初回レビュー本文、文書概要)→ 勘所①の 会話変数 に明示的に畳んでおく
- 会話の自然な流れ(「さっき言ったやつ」の解決など)→ メモリ(会話履歴) に任せる
ひとことでまとめると、「事実は会話変数、流れはメモリ」。 メモリはONにする、でも大事な事実はメモリに預けっぱなしにしない。これがちょうどいいバランスでした。
まとめ
会話型レビューBotのキモは、結局このひとことに尽きます。
「レビュー観点をプロンプトに固定する」発想から、「初回の結論を状態として畳んで、2回目以降は意図で振り分けて会話する」発想へ切り替えること。
最後に、まとめると下記になります。
- 初回/追問を 会話変数のフラグ で分けたか
- 追問の中を 質問分類器(or 構造化出力) で「再検索/深掘り」に割ったか
- 追問で必要な前提を 初回のうちに会話変数へ保存したか(前ノード直結に頼らない)
- 事実は会話変数・流れはメモリ で役割分担できているか
この型はレビューBotに限らず、「最初に重い処理をして、以降はその結果について対話する」系のチャットボット全般(要約Bot、診断Bot、申請チェックBotなど)に応用できます。 似たようなBotで詰まっている方の、何かのヒントになれば嬉しいです。
付録:主要ノード構成(一般化)
| ノード | 種類 | 役割 |
|---|---|---|
| 開始 | start | PDF/発話の受け取り |
| 初回判定 | if-else | 会話変数 is_reviewed の空判定で初回/追問を分岐 |
| ドキュメント解析 | llm(構造化出力) | 概要抽出 |
| ナレッジ検索 | knowledge-retrieval | 過去事例をRAG検索 |
| フルレビュー | llm(構造化出力) | 観点別レビューを生成(初回専用) |
| 会話変数保存 | assigner | フラグ・レビュー本文・概要を保存 |
| 質問分類器 | question-classifier | 追問の意図を「再検索/深掘り」に分類 |
| 会話回答 | llm(メモリON) | 保存済み文脈+(必要なら)再検索結果で対話的に回答 |