用 LangChain 构建电影 RAG Agent:向量检索、重排序与 Human-in-the-loop

用 LangChain 构建电影 RAG Agent:向量检索、重排序与 Human-in-the-loop

本文基于项目中的 rag-demo.py,介绍如何用 LangChain v1 + LangGraph 搭建一个「查电影 → 提取关键词 → 人工审批后写入」的 RAG Agent。完整源码见 rag-demo.py,流程速查见 rag-demo-flow.md


背景:我们到底要解决什么问题?

想象这样一个场景:用户输入「请查询电影《Kampfansage》,提取关键词并保存」。Agent 需要:

  1. 从上千部电影里找到正确的那一部(可能片名不完整、还是德语);
  2. 读懂剧情后生成 5 个英文关键词
  3. 把关键词写回向量库和本地 JSON,供后续检索使用。

第 3 步是有副作用的写操作——如果 LLM 幻觉出错误关键词,会直接污染数据。因此我们在 demo 里加了一层 Human-in-the-loop(HITL):检索自动执行,保存前必须人工点批准。

整体架构如下:

flowchart LR A[用户] --> B[LLM Agent] B --> C[search_movie] C --> D[(Chroma)] B --> E[save_movie_keywords] E --> F{人工审批} F -->|approve| G[写入 Chroma + dataset.text] F -->|reject| B

技术选型

层次 选型 理由
数据源 MongoDB/embedded_movies 自带电影 metadata,适合 RAG demo
嵌入 BAAI/bge-m3 多语言、长文本,德语/日语片名比 MiniLM 稳
重排序 BAAI/bge-reranker-v2-m3 向量召回 Top-K 后再精排,减少误匹配
向量库 Chroma 轻量、本地持久化
Agent create_agent + LangGraph 工具调用 + checkpoint + middleware
HITL HumanInTheLoopMiddleware 写工具执行前 interrupt,人工 resume

第一步:准备数据,统一 ID

数据从 HuggingFace 下载,去掉无剧情的记录,并为每条电影生成稳定 id。这个 id 会同时出现在 JSON 源文件和 Chroma 里,后续更新关键词靠它对齐。

def download_dataset():
    dataset = hf_load_dataset("MongoDB/embedded_movies")
    dataset_df = pd.DataFrame(dataset["train"])
    dataset_df = dataset_df.dropna(subset=["plot"])
    dataset_df = dataset_df.drop(columns=["plot_embedding"])

    records = dataset_df.to_dict(orient="records")
    for record in records:
        if not record.get("id"):
            record["id"] = uuid.uuid4().hex

    with open(DATASET_PATH, "w", encoding="utf-8") as f:
        for record in records:
            f.write(json.dumps(record, ensure_ascii=False) + "\n")

每条电影被转成 LangChain Documentpage_content 里拼上标题、类型、导演、演员、剧情等字段——embedding 的是整段文本,而不只是标题

def _record_to_document(record: dict) -> Document:
    title = record.get("title", "Unknown")
    plot = record.get("fullplot") or record.get("plot", "")
    genres = ", ".join(record.get("genres") or [])
    # ...

    page_content = (
        f"Title: {title}\n"
        f"Genres: {genres}\n"
        f"Directors: {directors}\n"
        f"Cast: {cast}\n"
        f"Plot: {plot}\n"
        # ...
    )

    return Document(
        page_content=page_content,
        metadata={"title": title, "id": record.get("id", ""), ...},
    )

然后批量嵌入并写入 Chroma:

EMBEDDING_MODEL = "BAAI/bge-m3"

def embed_dataset(rebuild: bool = True):
    if rebuild and CHROMA_DIR.exists():
        shutil.rmtree(CHROMA_DIR)

    documents, ids = [], []
    for record in load_dataset():
        if record.get("plot") or record.get("fullplot"):
            documents.append(_record_to_document(record))
            ids.append(record.get("id"))

    return Chroma.from_documents(
        documents=documents,
        embedding=HuggingFaceEmbeddings(model_name=EMBEDDING_MODEL),
        ids=ids,
        persist_directory=str(CHROMA_DIR),
        collection_name="movies",
    )

踩坑提示:换嵌入模型后必须 rebuild=True 重建向量库。MiniLM(384 维)和 bge-m3(1024 维)不能混用。首次下载 bge-m3 约 2GB,国内网络建议设置 HF_ENDPOINT=https://hf-mirror.com


第二步:两阶段检索——向量召回 + Cross-Encoder 重排

单纯 similarity_search(query, k=1) 容易翻车。例如输入 Kampfansage,向量库里的完整片名是 Kampfansage - Der letzte Schèler,精确 metadata 匹配失败;而英文 embedding 可能把 Kamikaze Taxi(剧情含 kamikaze)排到第一。

当前 demo 的检索策略是:

  1. Chroma metadata 精确匹配 title
  2. 向量召回 Top-15 + bge-reranker 精排取 Top-1
RERANKER_MODEL = "BAAI/bge-reranker-v2-m3"

def find_movie_by_title(vectorstore: Chroma, title: str) -> Document | None:
    result = vectorstore.get(where={"title": title}, limit=1)
    if result["ids"]:
        return Document(
            page_content=result["documents"][0],
            metadata=result["metadatas"][0] or {},
            id=result["ids"][0],
        )

    candidates = vectorstore.similarity_search(title, k=15)
    if not candidates:
        return None

    reranker = CrossEncoderReranker(
        model=HuggingFaceCrossEncoder(model_name=RERANKER_MODEL),
        top_n=1,
    )
    return reranker.compress_documents(candidates, query=title)[0]

代码里还保留了 find_record_by_title_fuzzy()(子串匹配标题),目前被注释。对「只输入部分片名」的场景,模糊标题匹配往往比纯向量更可靠,生产环境建议重新启用作为 step 2,向量检索作为兜底。


第三步:把检索封装成 Agent 工具

Agent 不直接碰 Chroma,而是通过 @tool 暴露能力:

@tool
def search_movie(title: str) -> str:
    """从向量库 RAG 检索电影。传入电影名,返回剧情、演员、类型、doc_id 等信息。"""
    doc = find_movie_by_title(get_vectorstore(), title)
    if doc is None:
        return f"未找到电影: {title}"

    return (
        f"title: {doc.metadata.get('title')}\n"
        f"doc_id: {doc.id}\n"
        f"genres: {doc.metadata.get('genres', '')}\n"
        f"keywords: {doc.metadata.get('keywords') or '无'}\n"
        f"content:\n{doc.page_content}"
    )


@tool
def save_movie_keywords(record_id: str, keywords: str) -> str:
    """将关键词保存到向量库,并同步到 dataset 源文件。"""
    vectorstore = get_vectorstore()
    _update_movie_keywords(vectorstore, record_id, keywords)
    _sync_keywords_to_dataset(record_id, keywords)
    # ...

save_movie_keywords 做两件事:

  • _update_movie_keywords:改 Chroma 文档内容并重新 embedding
  • _sync_keywords_to_dataset:写回 dataset.text JSONL

双写保证向量库与源文件一致。


第四步:Human-in-the-loop——写操作必须人工点头

LangChain 的 HumanInTheLoopMiddleware 会在 Agent 发出工具调用后、真正执行前 interrupt。要 resume,必须:

  1. 配置 checkpointer(这里用 InMemorySaver
  2. 每次 invoke 使用相同的 thread_id
  3. 人工决策后,用 Command(resume={"decisions": [...]}) 继续

Agent 创建:

_checkpointer = InMemorySaver()

def create_movie_rag_agent():
    return create_agent(
        model=chat_llm(),
        system_prompt=SYSTEM_PROMPT,
        tools=[search_movie, save_movie_keywords],
        checkpointer=_checkpointer,
        middleware=[
            HumanInTheLoopMiddleware(
                interrupt_on={
                    "search_movie": False,          # 只读,自动执行
                    "save_movie_keywords": {         # 写操作,需审批
                        "allowed_decisions": ["approve", "edit", "reject"],
                    },
                },
                description_prefix="保存关键词前需人工确认",
            ),
        ],
    )
工具 interrupt 说明
search_movie False 查询无副作用,不打扰用户
save_movie_keywords approve / edit / reject 写库前必须人工确认

invoke → interrupt → resume 循环

def invoke_with_hitl(agent, payload, config: dict):
    result = agent.invoke(payload, config=config, version="v2")

    while result.interrupts:
        interrupt_value = result.interrupts[0].value
        action_requests = interrupt_value["action_requests"]

        # 展示待审批的工具和参数,收集人工决策
        _print_hitl_request(interrupt_value)
        decisions = [
            _prompt_hitl_decision(action, ...)
            for action in action_requests
        ]

        result = agent.invoke(
            Command(resume={"decisions": decisions}),
            config=config,
            version="v2",
        )

    return result

三种决策的含义:

  • approve:按 LLM 原参数执行工具
  • edit:人工修改 record_id / keywords 后再执行
  • reject:不执行,把拒绝原因反馈给 LLM,由 Agent 重新组织回复

终端交互示例:

--- 待审批工具调用 ---
[1] 工具: save_movie_keywords
    参数: {"record_id": "8d417880...", "keywords": "Action, Revenge, ..."}

审批 `save_movie_keywords`
人工决策 (approve/edit/reject): e
请输入修改后的 args(JSON):
> {"record_id": "8d417880...", "keywords": "Post-apocalyptic, Martial arts, Survival"}

第五步:跑起来

.env 中配置 LLM:

base_url=https://your-api-endpoint/v1
api_key=sk-xxx
model=your-model-name

离线建库(首次或换模型后):

download_dataset()
embed_dataset(rebuild=True)

在线交互(默认入口):

python rag-demo.py

程序进入 run_interactive_hitl(),多轮对话共享同一 thread_id,写操作每次都会弹出审批。

System Prompt 约束 Agent 的行为顺序:

1. 先 search_movie
2. 提取 5 个英文关键词
3. 再 save_movie_keywords
4. 向用户说明结果

一次完整对话里发生了什么?

用户输入:

请查询电影《Kampfansage》,提取关键词并保存

阶段 发生了什么
1 LLM 调用 search_movie("Kampfansage") → 自动执行,无 interrupt
2 检索模块走向量召回 + rerank,返回电影详情和 doc_id
3 LLM 根据剧情生成关键词,调用 save_movie_keywords(...)
4 HumanInTheLoopMiddleware 触发 interrupt,终端等待人工输入
5 用户 approve / edit / reject
6 Command(resume=...) 恢复执行;若 approve,双写 Chroma + JSON
7 LLM 生成最终自然语言回复
sequenceDiagram participant U as 用户 participant A as Agent/LLM participant S as search_movie participant H as HITL participant W as save_movie_keywords U->>A: 查 Kampfansage 并保存关键词 A->>S: tool call(自动) S-->>A: 电影详情 + doc_id A->>W: tool call W->>H: interrupt H->>U: 展示参数,等待审批 U->>H: approve / edit / reject H->>W: resume 后执行或跳过 W-->>A: 工具结果 A-->>U: 最终回复

设计取舍与可改进点

已做的

  • 多语言嵌入 + rerank,比单用 MiniLM 稳
  • 读/写工具分离,HITL 只拦写操作
  • JSONL 与 Chroma 共用 id,更新路径清晰

可改进

  1. 重新启用模糊标题匹配:部分片名输入时比纯向量更准
  2. 嵌入模型懒加载 + 下载进度:bge-m3 首次加载易误以为卡死
  3. HITL 接入 Web UI:终端 input() 适合 demo,生产需审批台
  4. RETRIEVE_Kk=15 统一:常量已定义但未完全使用

总结

rag-demo.py 串联了 RAG 管线里几个常见环节:离线建库 → 向量召回 → 重排序 → Agent 工具化 → 写操作人工审批。它不是一个「聊天查电影」的简单 demo,而是一个可扩展的骨架——你可以把 save_movie_keywords 换成「写入 CMS」「发邮件通知」等任何敏感工具,同样用 HumanInTheLoopMiddleware 挂一层安全阀。

核心链路一句话概括:

用户说话 → Agent RAG 查电影 → LLM 提议保存 → 人工审批 → 双写向量库与 JSON → Agent 回复。


完整代码

import uuid

from datasets import load_dataset as hf_load_dataset
import pandas as pd
import json
import shutil
from pathlib import Path

from langchain.agents import create_agent
from langchain.agents.middleware import HumanInTheLoopMiddleware
from langchain.tools import tool
from langchain_chroma import Chroma
from langchain_classic.retrievers.document_compressors import CrossEncoderReranker
from langchain_community.cross_encoders import HuggingFaceCrossEncoder
from langchain_core.documents import Document
from langchain_huggingface import HuggingFaceEmbeddings
from langchain_openai import ChatOpenAI
import dotenv
import os
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import Command

dotenv.load_dotenv()


# https://huggingface.co/datasets/AIatMongoDB/embedded\_movies

DATASET_PATH = Path("dataset.text")
CHROMA_DIR = Path("./chroma_db")
EMBEDDING_MODEL = "BAAI/bge-m3"
RERANKER_MODEL = "BAAI/bge-reranker-v2-m3"
RETRIEVE_K = 20
COLLECTION_NAME = "movies"


def get_embeddings() -> HuggingFaceEmbeddings:
    return HuggingFaceEmbeddings(model_name=EMBEDDING_MODEL)

def get_reranker() -> CrossEncoderReranker:
    cross_encoder = HuggingFaceCrossEncoder(model_name=RERANKER_MODEL)
    return CrossEncoderReranker(model=cross_encoder, top_n=1)

def download_dataset():
    dataset = hf_load_dataset("MongoDB/embedded_movies")

    dataset_df = pd.DataFrame(dataset['train'])
    dataset_df = dataset_df.dropna(subset=['plot'])
    print("\nNumber of missing values in each column after removal:")
    print(dataset_df.isnull().sum())
    dataset_df = dataset_df.drop(columns=['plot_embedding'])

    # 写入源文件时生成稳定 id,后续向量库与 JSON 共用同一 id
    records = dataset_df.to_dict(orient="records")
    for record in records:
        if not record.get("id"):
            record["id"] = uuid.uuid4().hex

    with open(DATASET_PATH, 'w', encoding='utf-8') as f:
        for record in records:
            f.write(json.dumps(record, ensure_ascii=False) + "\n")


def load_dataset():
    """从 JSONL 文件加载电影数据(每行一条 JSON 记录)。"""
    data = []
    with open(DATASET_PATH, 'r', encoding='utf-8') as f:
        for line in f:
            line = line.strip()
            if line:
                data.append(json.loads(line))
    return data


def find_record_by_id(record_id: str) -> dict | None:
    """根据 id 从源数据中查找记录。"""
    for record in load_dataset():
        if record.get("id") == record_id:
            return record
    return None


def find_record_by_title_fuzzy(title: str) -> dict | None:
    """按标题模糊匹配:支持用户只输入片名的一部分(如 Kampfansage)。"""
    query = title.strip().lower()
    if not query:
        return None

    candidates: list[dict] = []
    for record in load_dataset():
        record_title = (record.get("title") or "").strip()
        if not record_title:
            continue
        record_lower = record_title.lower()
        if query == record_lower:
            return record
        if query in record_lower or record_lower in query:
            candidates.append(record)

    if not candidates:
        return None
    # 多条命中时取标题最短的一条,通常与用户输入最接近
    return min(candidates, key=lambda r: len(r.get("title") or ""))


def _record_to_document(record: dict) -> Document:
    """将单条电影记录转为 LangChain Document。"""
    title = record.get("title", "Unknown")
    plot = record.get("fullplot") or record.get("plot", "")
    genres = ", ".join(record.get("genres") or [])
    cast = ", ".join(record.get("cast") or [])
    directors = ", ".join(record.get("directors") or [])
    writers = ", ".join(record.get("writers") or [])
    languages = ", ".join(record.get("languages") or [])

    keywords = record.get("keywords") or []
    if isinstance(keywords, list):
        keywords = ", ".join(keywords)

    page_content = (
        f"Title: {title}\n"
        f"Genres: {genres}\n"
        f"Directors: {directors}\n"
        f"Cast: {cast}\n"
        f"Plot: {plot}\n"
        f"Writers: {writers}\n"
        f"Languages: {languages}\n"
    )
    if keywords:
        page_content += f"Keywords: {keywords}\n"

    return Document(
        page_content=page_content,
        metadata={
            "title": title,
            "type": record.get("type", ""),
            "genres": genres,
            "keywords": keywords,
            "id": record.get("id", "")
        },
    )


def embed_dataset(persist_directory: str | Path = CHROMA_DIR, rebuild: bool = True):
    """读取 dataset,生成 embedding 并写入 Chroma 向量库。"""
    persist_directory = Path(persist_directory)
    if rebuild and persist_directory.exists():
        shutil.rmtree(persist_directory)

    embeddings = get_embeddings()
    data = load_dataset()

    documents = []
    ids = []
    for index, record in enumerate(data):
        if not (record.get("plot") or record.get("fullplot")):
            continue
        documents.append(_record_to_document(record))
        ids.append(record.get("id"))
    print(f"Loaded {len(documents)} documents from {DATASET_PATH}")
    vectorstore = Chroma.from_documents(
        documents=documents,
        embedding=embeddings,
        ids=ids,
        persist_directory=str(persist_directory),
        collection_name=COLLECTION_NAME,
    )
    print(f"Embeddings saved to {persist_directory}")
    return vectorstore


def load_vectorstore(persist_directory: str | Path = CHROMA_DIR) -> Chroma:
    """加载已持久化的 Chroma 向量库。"""
    return Chroma(
        persist_directory=str(persist_directory),
        embedding_function=get_embeddings(),
        collection_name=COLLECTION_NAME,
    )

def find_movie_by_title(vectorstore: Chroma, title: str) -> Document | None:
    """按标题查找电影:metadata 精确匹配 → 源数据模糊标题 → 向量相似度。"""
    title = title.strip()
    if not title:
        return None

    result = vectorstore.get(where={"title": title}, limit=1)
    print("step1 ", result)
    if result["ids"]:
        return Document(
            page_content=result["documents"][0],
            metadata=result["metadatas"][0] or {},
            id=result["ids"][0],
        )

    # fuzzy_record = find_record_by_title_fuzzy(title)
    # if fuzzy_record and fuzzy_record.get("id"):
    #     docs = vectorstore.get_by_ids([fuzzy_record["id"]])
    #     if docs:
    #         doc = docs[0]
    #         doc.id = fuzzy_record["id"]
    #         return doc

    candidates = vectorstore.similarity_search(title, k=15)

    print("step2 ", candidates)
    if not candidates:
        return None
    return get_reranker().compress_documents(candidates, query=title)[0]


def _update_movie_keywords(vectorstore: Chroma, doc_id: str, keywords: str) -> Document:
    """更新向量库中电影的关键词,并重新计算 embedding。"""
    docs = vectorstore.get_by_ids([doc_id])
    if not docs:
        raise ValueError(f"Document not found: {doc_id}")

    doc = docs[0]
    base_content = doc.page_content.split("\nKeywords:")[0].rstrip()
    updated_doc = Document(
        page_content=f"{base_content}\nKeywords: {keywords}\n",
        metadata={**doc.metadata, "keywords": keywords},
    )
    vectorstore.update_document(doc_id, updated_doc)
    updated_doc.id = doc_id
    return updated_doc


def _sync_keywords_to_dataset(record_id: str, keywords: str) -> None:
    """根据 id 将关键词同步写回 JSON 源文件。"""
    records = load_dataset()
    updated = False
    for record in records:
        if record.get("id") == record_id:
            record["keywords"] = [k.strip() for k in keywords.split(",") if k.strip()]
            updated = True
            break
    if not updated:
        return

    with open(DATASET_PATH, "w", encoding="utf-8") as f:
        for record in records:
            f.write(json.dumps(record, ensure_ascii=False) + "\n")


_vectorstore: Chroma | None = None
_checkpointer = InMemorySaver()


def get_vectorstore() -> Chroma:
    global _vectorstore
    if _vectorstore is None:
        _vectorstore = load_vectorstore()
    return _vectorstore


@tool
def search_movie(title: str) -> str:
    """从向量库 RAG 检索电影。传入电影名,返回剧情、演员、类型、doc_id 等信息。"""
    doc = find_movie_by_title(get_vectorstore(), title)
    if doc is None:
        return f"未找到电影: {title}"

    return (
        f"title: {doc.metadata.get('title')}\n"
        f"doc_id: {doc.id}\n"
        f"genres: {doc.metadata.get('genres', '')}\n"
        f"keywords: {doc.metadata.get('keywords') or '无'}\n"
        f"content:\n{doc.page_content}"
    )


@tool
def save_movie_keywords(record_id: str, keywords: str) -> str:
    """将关键词保存到向量库,并同步到 dataset 源文件。record_id 来自 search_movie 返回的 doc_id。"""
    vectorstore = get_vectorstore()
    _update_movie_keywords(vectorstore, record_id, keywords)
    _sync_keywords_to_dataset(record_id, keywords)
    record = find_record_by_id(record_id)
    title = record.get("title") if record else record_id
    return f"已保存《{title}》的关键词: {keywords}"


SYSTEM_PROMPT = """你是专业的电影评论员,可以使用以下工具:

- search_movie: 从向量库 RAG 检索电影详情(必须先调用)
- save_movie_keywords: 保存关键词(需要 search_movie 返回的 doc_id)

工作流程:
1. 用户给出电影名时,先调用 search_movie 查询
2. 根据检索内容提取 5 个英文关键词
3. 调用 save_movie_keywords(record_id=doc_id, keywords=...) 保存
4. 向用户说明电影信息和已保存的关键词
"""


def chat_llm() -> ChatOpenAI:
    """与 LLM 进行对话。"""
    llm = ChatOpenAI(
        base_url=os.getenv("base_url"),
        api_key=os.getenv("api_key"),
        model=os.getenv("model"),
    )
    return llm


def create_movie_rag_agent():
    """创建带 Human-in-the-loop 的电影 RAG Agent(写操作需人工审批)。"""
    return create_agent(
        model=chat_llm(),
        system_prompt=SYSTEM_PROMPT,
        tools=[search_movie, save_movie_keywords],
        checkpointer=_checkpointer,
        middleware=[
            HumanInTheLoopMiddleware(
                interrupt_on={
                    "search_movie": False,
                    "save_movie_keywords": {
                        "allowed_decisions": ["approve", "edit", "reject"],
                    },
                },
                description_prefix="保存关键词前需人工确认",
            ),
        ],
    )


def _print_hitl_request(interrupt_value: dict) -> None:
    action_requests = interrupt_value["action_requests"]
    review_configs = interrupt_value["review_configs"]
    config_map = {cfg["action_name"]: cfg for cfg in review_configs}
    print("----------------------")
    print("interrupt_value ",json.dumps(interrupt_value, ensure_ascii=False))
    print("\n--- 待审批工具调用 ---")
    for index, action in enumerate(action_requests, start=1):
        review = config_map[action["name"]]
        print(f"[{index}] 工具: {action['name']}")
        print(f"    参数: {json.dumps(action['args'], ensure_ascii=False)}")
        if action.get("description"):
            print(f"    说明: {action['description']}")
        print(f"    可选: {review['allowed_decisions']}")
    print("----------------------")


def _prompt_hitl_decision(action: dict, allowed_decisions: list[str]) -> dict:
    print(f"\n审批 `{action['name']}`")
    print(json.dumps(action["args"], ensure_ascii=False, indent=2))

    while True:
        hint = "/".join(allowed_decisions)
        choice = input(f"人工决策 ({hint}): ").strip().lower()

        if choice in {"approve", "a", "y", "yes"} and "approve" in allowed_decisions:
            return {"type": "approve"}
        if choice in {"reject", "r", "n", "no"} and "reject" in allowed_decisions:
            message = input("拒绝原因(回车跳过): ").strip()
            decision: dict = {"type": "reject"}
            if message:
                decision["message"] = message
            return decision
        if choice in {"edit", "e"} and "edit" in allowed_decisions:
            print("请输入修改后的 args(JSON),例如:")
            print('  {"record_id": "xxx", "keywords": "Action, Drama, ..."}')
            raw = input("> ").strip()
            try:
                args = json.loads(raw)
                if "name" in args and "args" in args:
                    return {"type": "edit", "edited_action": args}
                return {
                    "type": "edit",
                    "edited_action": {"name": action["name"], "args": args},
                }
            except json.JSONDecodeError as err:
                print(f"JSON 无效: {err}")
        print("无效输入,请重试。")


def invoke_with_hitl(agent, payload, config: dict):
    """执行 Agent,遇到 interrupt 时暂停并等待人工审批后 resume。"""
    result = agent.invoke(payload, config=config, version="v2")

    while result.interrupts:
        interrupt_value = result.interrupts[0].value
        action_requests = interrupt_value["action_requests"]
        review_configs = interrupt_value["review_configs"]
        config_map = {cfg["action_name"]: cfg for cfg in review_configs}

        _print_hitl_request(interrupt_value)
        decisions = [
            _prompt_hitl_decision(action, config_map[action["name"]]["allowed_decisions"])
            for action in action_requests
        ]
        result = agent.invoke(
            Command(resume={"decisions": decisions}),
            config=config,
            version="v2",
        )

    return result


def _final_agent_text(result) -> str:
    value = result.value if hasattr(result, "value") else result
    messages = value.get("messages", [])
    if not messages:
        return ""
    return messages[-1].content


def run_interactive_hitl(thread_id: str = "movie-rag-hitl") -> None:
    """交互式对话 + Human-in-the-loop:save_movie_keywords 执行前需人工审批。"""
    agent = create_movie_rag_agent()
    config = {"configurable": {"thread_id": thread_id}}

    print("电影 RAG 助手(Human-in-the-loop)")
    print("- search_movie 自动执行")
    print("- save_movie_keywords 需人工 approve / edit / reject")
    print("- 输入 quit / 退出 结束\n")

    while True:
        try:
            user_input = input("你: ").strip()
        except (EOFError, KeyboardInterrupt):
            print("\n再见。")
            break

        if not user_input:
            continue
        if user_input.lower() in {"quit", "exit", "q", "退出", "bye"}:
            print("再见。")
            break

        try:
            result = invoke_with_hitl(
                agent,
                {"messages": [{"role": "user", "content": user_input}]},
                config,
            )
            print(f"助手: {_final_agent_text(result)}\n")
        except Exception as e:
            print(f"错误: {e}\n")


def execute_agent(movie_title: str):
    agent = create_movie_rag_agent()
    config = {"configurable": {"thread_id": "movie-rag-1"}}

    result = invoke_with_hitl(
        agent,
        {
            "messages": [
                {
                    "role": "user",
                    "content": f"请查询电影《{movie_title}》,提取关键词并保存",
                }
            ]
        },
        config,
    )
    print(_final_agent_text(result))

def verify(movie_title:str):
    vectorstore = get_vectorstore()
    doc = find_movie_by_title(vectorstore, movie_title)
    print(doc.metadata)


if __name__ == "__main__":
    run_interactive_hitl()

posted @ 2026-06-05 16:29  lyu6  阅读(19)  评论(0)    收藏  举报