sheldonlsides/langgraph-agent-guardrails
GitHub: sheldonlsides/langgraph-agent-guardrails
该项目通过一个多 Agent 膳食规划器用例,演示 LangGraph 中 fan-out/fan-in 并行编排、提示词注入防护和 pgvector RAG 检索的完整生产级实现模式。
Stars: 0 | Forks: 0
# LangGraph Fan-Out、Guardrails 与 pgvector RAG:实战案例
[](https://github.com/sheldonlsides/langgraph-agent-guardrails/actions/workflows/ci.yml)
[](LICENSE)
[](https://www.python.org/downloads/)
一个独立的 LangGraph notebook,演示了针对使用工具的 agent 的三种生产级模式:
- **使用 `Send()` 实现 Fan-out / fan-in** 以及 `operator.add` reducer,在一个 barrier 节点汇合,该节点运行确定性的 Python 代码,而不是将算术运算交给 LLM 处理。
- **分层提示词注入防御。** 一个结构化的 `ScopeCheck` 门禁会在任何工具运行之前拒绝超出范围的请求,每个 agent 的系统提示词中都带有 `GUARDRAILS` 前缀,并且所有的网络和工具输出都被视为不受信任的数据。
- **pgvector RAG 基础支撑**,使用 `langchain-postgres` v2(`PGVectorStore` + HNSW 余弦索引),只需嵌入一次,并在后续每次运行中实现幂等加载。
本示例中的用例是一个膳食规划器。你可以给它一个类似“1天的高蛋白、低碳水计划,1800 到 2000 千卡,包含零食,对贝类过敏”的请求,然后一组 agent 会规划出独特的菜品,并行研究每道菜的真实食谱,将每种食材与营养数据库进行比对,并生成一份经过卡路里检查的 markdown 膳食计划。每一个宏量营养素数据都是在 Python 中计算的,而不是由 LLM 凭空生成的。
所有内容都集中在一个 notebook 中:`src/agent_guardrails.ipynb`。
## 你将获得什么
一个独立的 notebook,端到端地构建图表:
- 一个具有动态 fan-out、确定性 barrier 和拒绝短路机制的 `StateGraph`。
- 一个基于 pgvector 的检索工具,针对真实的食物数据库(来自 OpenNutrition 的约 32.7 万行数据)。
- 一个与提供商无关的模型工厂(`create_model()`),可通过环境变量在 Bedrock、OpenAI 和 Anthropic 之间切换。
- 一个范围门禁和 guardrail 前缀,在任何工具运行之前阻止超出范围和注入式的请求。
- 为每个膳食计划提供确定性的 markdown 渲染,并在每一步使用 Python 重新计算宏量营养素。
## 你将学到什么
- **LangGraph fan-out / fan-in**:使用 `Send(...)` 和 `operator.add` reducer 实现动态并行,并在 barrier 节点将 worker 的结果拼接起来。
- **范围门控与提示词注入防御**:一个单一的 `GUARDRAILS` 块,一个结构化的 `ScopeCheck` 门禁,用于在任何工具运行*之前*拒绝超出范围的请求,并将所有网络/工具输出视为不受信任的数据。
- **使用 pgvector 进行 RAG 基础支撑**:在本地嵌入大型数据集,并使用 `langchain-postgres` v2(`PGVectorStore` + HNSW 余弦索引)进行查询。
- **将数学计算排除在模型之外**:结合结构化的 Pydantic 输出和确定性的 Python 代码,处理用户看到的每一个数字。
- **与提供商无关的模型工厂**:只需调用一次 `create_model()` 即可在 Bedrock、OpenAI 和 Anthropic 之间切换,无需更改代码。
- **一个能验证自身声明的 notebook**:最后的单元格作为一次验证过程运行,对应 [OWASP Top 10 for LLM Applications (2025)](https://genai.owasp.org/llm-top-10/)。四个探测涵盖了直接提示词注入 (LLM01)、通过投毒工具输出进行的间接提示词注入 (LLM01)、捏造的宏量营养素与上限绕过 (LLM09 Misinformation),以及不安全的 agent 间通信 (LLM01 加上 LLM05 Improper Output Handling)。如果 notebook 能够端到端运行,这些声明就成立。
## 为什么会有这个项目
众所周知,LLM 并不擅长数学。如果你让 LLM 对一周膳食中的一项宏量营养素数据进行求和,错误可能会悄无声息地累积。在任何需要使用工具的 agent 汇总数字事实的地方都会出现同样的模式:发票、财务摘要、容量规划,只要一个小小的舍入误差就会在下游变成巨大的报告错误。
解决方法是让模型进行规划和研究,然后让确定性的 Python 完成数学计算。这个 notebook 以膳食规划器为用例,端到地展示了这种分工。食材在包含数值营养信息的 pgvector 存储中进行查找;份量按克重进行缩放;每餐、每天以及总计都是代码中的求和结果。模型负责挑选菜品、寻找食谱和估计份量大小。
同一个 notebook 还演示了动态并行(每个膳食时段一个 Recipe worker,并发运行)和针对提示词注入的分层防御。这些模式是可复用的部分;膳食规划 agent 则用于展示它们的实际运行效果。
## 工作原理一览
```
flowchart TD
START([request]) --> CHEF[chef_plan
ScopeCheck + unique dish per slot] CHEF -->|out of scope| SUM[chef_summary] CHEF -->|in scope: Send fan-out| W1[recipe_worker 1] CHEF --> W2[recipe_worker 2] CHEF --> WN[recipe_worker N] W1 --> BAR[meal_planner barrier
deterministic macros + render] W2 --> BAR WN --> BAR BAR --> SUM SUM --> END([END]) ``` - **`chef_plan`** *(Chef, temp 0.7)*:运行 `ScopeCheck`;如果请求与食物无关,它会拒绝,并且整个图表会短路。否则,它会解析天数/餐数/零食数量以及任何卡路里范围,然后为**每个时段分配一道独特的菜品**(例如:*Day 1 Breakfast → Spinach & Feta Egg Scramble*)。 - **`route_after_chef`**:条件边缘。如果拒绝,它直接路由到摘要;如果在范围内,它会为每个计划的膳食发出一个 `Send("recipe_worker", RecipeTask(...))`(即 fan-out)。 - **`recipe_worker`** *(Recipe, temp 0)*:每个时段一个并行 worker。它在网络上搜索分配到的确切菜品 (Tavily),抓取页面,将每种食材在 pgvector 中进行比对,根据该餐的卡路里预算估计份量克数,并发出结构化的 `Recipe`。Workers 将结果追加到 `recipes: Annotated[list[Recipe], operator.add]`(即 fan-in)。 - **`meal_planner`** *(Planner, temp 0)*:barrier;在所有 worker 完成后运行一次。它通过缩放份量来强制执行每日卡路里上限(`_enforce_calories`,上限为 `MAX_PORTION_SCALE = 2.5`),仅要求模型提供标题和一句话介绍,然后确定性地渲染 markdown(`_render_plan_md`)并将其写入磁盘。 - **`chef_summary`**:使用保存的路径和卡路里范围(或拒绝信息)闭环。 状态存在于一个 `ChefState` Pydantic 模型中,并流经每个节点。 ## 适用人群 你熟悉 Python 并且之前见过 LLM agent;你不需要有 LangGraph 的使用经验。这个 notebook 会一步步构建图表。如果 `StateGraph`、节点和边缘对你来说是新概念,我建议你先看看 [LangGraph 快速入门](https://langchain-ai.github.io/langgraph/)。 这个 notebook 是本地优先的。它需要一个本地的 PostgreSQL 加上 pgvector 数据库。下面的快速入门只需几个命令即可让你运行起来。 ## 包含内容 | 路径 | 用途 | |------|---------| | `src/agent_guardrails.ipynb` | 全部内容:vector store、工具、guardrails、agent、图表以及自上而下的示例运行。 | | `src/common/model_factory.py` | `create_model()`:与提供商无关的 LangChain 聊天模型(Bedrock / OpenAI / Anthropic),缺少配置时快速失败。 | | `src/vectorstore/` | pgvector 后端(`build_or_load_pgvector`):幂等地构建/加载营养表。 | | `deploy/` | 用于 PostgreSQL + pgvector 的 Docker Compose (`pgvector/pgvector:pg17`)。详见 `deploy/README.md`。 | | `docs/pgvector-setup.md` | pgvector 调优说明及 AWS RDS 路径。 | | `src/data/` | OpenNutrition TSV 文件的存放位置(约 269 MB,**已 gitignored**;由 `scripts/download_data.py` 自动获取)。 | | `.env.example` | 所有环境变量均有文档说明。复制为 `.env`。 | ## 快速开始(本地) 需要 **Python 3.12** 和 [uv](https://docs.astral.sh/uv/) 包管理器。从**项目根目录**运行以下所有命令,并始终通过 `uv run` 运行,以便使用项目的 `.venv`(而不是系统/Anaconda Python)。 ``` # 安装依赖(从已提交的 lockfile) uv sync # or: ./install_deps # 配置 secrets。复制模板并填写, cp .env.example .env # 设置 LLM_PROVIDER + LLM_PROVIDER_MODEL、匹配的 provider key、 # TAVILY_API_KEY 和 DATABASE_URL(见下表) # 启动 PostgreSQL + pgvector (Docker) cd deploy && docker compose up -d && cd .. # details in deploy/README.md # 获取营养数据集(约 60 MB zip → 约 269 MB TSV 位于 src/data/ 中) # notebook 会在首次运行时自动下载;或者现在获取: uv run python scripts/download_data.py # idempotent: skips if already present # 以 headless 方式运行 notebook ... uv run --with nbconvert -- jupyter nbconvert --to notebook --execute src/agent_guardrails.ipynb # ... 或以交互方式打开 uv run --with nbconvert jupyter notebook src/agent_guardrails.ipynb ``` **首次运行会很慢,仅此一次。** 构建向量表会在 CPU 上以每批 5,000 条的速度对完整的约 326,759 种食物的数据集进行嵌入,并构建 HNSW 索引(这将需要几分钟时间)。之后的每次运行都是瞬间完成的,通过行数检查即可加载现有表,无需重新嵌入(删除表以重建)。 生成的计划将输出到 `src/meal_plans/.md`(已 gitignored)。
## 环境变量
将 `.env.example` 复制为 `.env`。如果缺少所需的变量,`create_model()` 和 pgvector 后端将**快速失败**。
| 变量 | 是否必需? | 用于 | 说明 |
|----------|-----------|----------|-------|
| `LLM_PROVIDER` | ✅ | 选择 LLM 后端 | `bedrock` \| `openai` \| `anthropic` |
| `LLM_PROVIDER_MODEL` | ✅ | 该提供商的模型 ID | 例如 `gpt-4o-mini`, `claude-sonnet-4-6` |
| `OPENAI_API_KEY` | 如果使用 `openai` 则必需 | OpenAI 凭证 | [获取密钥](https://platform.openai.com/api-keys) |
| `ANTHROPIC_API_KEY` | 如果使用 `anthropic` 则必需 | Anthropic 凭证 | [获取密钥](https://console.anthropic.com/settings/keys);Bedrock 使用 AWS 凭证 / `AWS_REGION` 代替([启用模型访问](https://console.aws.amazon.com/bedrock/home#/modelaccess)) |
| `TAVILY_API_KEY` | ✅ | 网络食谱搜索 | [获取密钥](https://app.tavily.com/home);由 `tavily_search` 工具使用 |
| `DATABASE_URL` | ✅ | pgvector 连接 | psycopg3 URL,例如 `postgresql+psycopg://dev:devpass@localhost:5433/appdb` |
| `HF_TOKEN` | 可选 | 下载 Embedding 模型 | [获取 token](https://huggingface.co/settings/tokens);仅在下载受限制/私有的 HF 模型时需要 |
| `USER_AGENT` | 可选 | 用于页面抓取的出站 HTTP header | 默认为 `langgraph-agent-guardrails/1.0` |
| `LANGSMITH_API_KEY` / `LANGSMITH_ENDPOINT` / `LANGSMITH_PROJECT` | 可选 | LangSmith tracing | [获取密钥](https://smith.langchain.com/settings) |
## 架构说明
- **一个状态对象,一个 reducer。** `ChefState` 流经每个节点。它唯一带有 reducer 的字段是 `recipes: Annotated[list[Recipe], operator.add]`,因此 N 个并行 worker 各自追加一个食谱,结果在 barrier 处干净地拼接在一起。其他所有字段都是简单的最后写入者胜出。
- **Fan-out 是动态的。** `route_after_chef` 返回一个 `Send("recipe_worker", ...)` *列表*,其大小与计划相匹配,因此 3 餐的一天会产生 3 个 worker,5 餐的一天会产生 5 个。没有硬编码的宽度。
- **范围门禁首先运行并阻止一切。** 超出范围的请求永远不会到达工具、网络搜索或文件系统:`chef_plan` 返回拒绝,并且 `route_after_chef` 跳转到摘要。深度防御:`GUARDRAILS` 也会被添加到每个 agent 的系统提示词前,并且 Tavily 结果 / 抓取的页面被视为**数据,而不是指令**。
- **宏量营养素是代码,而不是模型输出。** `find_ingredients` 从 pgvector 返回每 100g 的营养数据;`total_meal` 根据克数进行缩放和求和;`_recompute_recipe_macros` 和 `_render_plan_md` 生成表格和总计。模型绝不会对用户看到的任何数字进行相加。
- **pgvector,只构建一次。** `build_or_load_pgvector` 通过**行数门禁**实现幂等:已填充的表按原样加载,从不重新嵌入。HNSW 余弦索引是在*批量加载之后*构建的(比逐条插入便宜得多),并且每一行稳定的 OpenNutrition ID 是主键,因此结果始终可追溯到源头。
- **每个 agent 单独的温度,一个工厂。** `create_model()` 被调用三次:Chef 为 `0.7`(创造性的菜品规划),Recipe 和 Planner 为 `0`(确定性)。提供商完全由环境变量决定。
## 常见陷阱
- **一切操作请使用 `uv run`。** 使用系统/Anaconda 内核启动 notebook 会引入不匹配的依赖;上面的 `uv run` 命令会将其固定到项目的 `.venv`。
- **`DATABASE_URL` 是必需的。** 营养存储仅支持 pgvector。没有本地文件的回退机制。在运行之前使用 `deploy/` 搭建好数据库,并注意示例端口是 **5433**(5432 通常是主机原生的 Postgres)。
- **数据集不在代码仓库中。** `opennutrition_foods.tsv`(约 269 MB)超出了 GitHub 的文件大小限制,已被 gitignored。它是从官方 OpenNutrition 源获取的。该 notebook 在首次运行时会自动下载,或者运行 `uv run python scripts/download_data.py`。当 TSV 已经在 `src/data/` 中时,两者都会跳过下载。
- **首次构建确实很慢。** 在 CPU 上嵌入约 32.7 万种食物需要几分钟。这是一次性的;后续的运行加载是瞬间完成的。在配置单元格中设置 `MAX_ROWS` 上限以获取快速开发子集。
- **饮食限制包含在请求中。** 过敏和偏好(*“对贝类过敏”*、*“素食”*)写在自然语言请求中。Chef 在规划菜品时会解析它们。
- **不要信任抓取的页面。** 食谱页面和搜索结果仅用于提取事实;guardrails 明确禁止遵循在其中发现的任何指令。
- **`torch==2.12.0` 是硬性指定的。** 这是出于对 embedding 模型的可重复性考虑。如果 `uv sync` 无法为你的平台解析 torch wheel(较旧的 CUDA、某些 Linux glibc 版本或特定的 Apple Silicon 路径),请先安装与你硬件匹配的 torch,然后重新运行 `uv sync`。
## 示例输出
以下是一个一日高蛋白请求的代表性生成计划。
```
# 高蛋白低碳日
A shellfish-free day built around lean protein and non-starchy vegetables.
> **Note:** Day 1 portions scaled ×1.12 to land inside the 1,800–2,000 kcal target.
## Day 1
### Day 1 早餐:菠菜羊奶酪炒蛋
A quick three-egg scramble with wilted spinach and a little feta.
| Ingredient | Amount | Calories | Protein (g) | Carbs (g) | Sugars (g) | Fat (g) |
|------------|--------|---------:|------------:|----------:|-----------:|--------:|
| Eggs | 3 (150 g) | 215 | 18.8 | 1.1 | 1.1 | 14.9 |
| Spinach | 60 g | 14 | 1.7 | 2.2 | 0.3 | 0.2 |
| Feta | 30 g | 79 | 4.3 | 1.2 | 1.2 | 6.4 |
**Meal total:** calories=308, protein=24.8g, carbs=4.5g, fat=21.5g
**Recipe** - 10 min
1. Whisk the eggs...
2. ...
_Source: https://example.com/spinach-feta-scramble_
## 最终总计
| Day | Calories | Carbs (g) | Protein (g) | Fat (g) |
|-------|---------:|----------:|------------:|--------:|
| Day 1 | 1,932 | 58 | 148 | 118 |
```
## 库版本固定
Python **3.12**,依赖项锁定在 `uv.lock` 中。`torch` 已固定版本(`pyproject.toml` 中的 `torch==2.12.0`),这是 macOS 上 embedding 技术栈的已知良好组合。除非你已经测试过升级,否则请保持该固定版本。
## 安全
有关如何私下报告漏洞,请参阅 [SECURITY.md](SECURITY.md)。
## 许可证
在 [MIT 许可证](LICENSE) 下发布。
## 数据与归属
营养数据来自 [OpenNutrition](https://www.opennutrition.app),基于 [Open Database License (ODbL)](https://opendatacommons.org/licenses/odbl/) 获得许可,其内容基于 DbCL。代码仅会在运行时从官方来源**获取**数据集(此处不进行再分发),但它会**显示** OpenNutrition 的数据,因此生成的膳食计划会在文中注明 OpenNutrition 的归属。从中派生的任何数据库(例如 pgvector 表)均继承 ODbL 的相同方式共享条款。上面的 MIT 许可证涵盖本项目的**代码**,不包括营养数据。
ScopeCheck + unique dish per slot] CHEF -->|out of scope| SUM[chef_summary] CHEF -->|in scope: Send fan-out| W1[recipe_worker 1] CHEF --> W2[recipe_worker 2] CHEF --> WN[recipe_worker N] W1 --> BAR[meal_planner barrier
deterministic macros + render] W2 --> BAR WN --> BAR BAR --> SUM SUM --> END([END]) ``` - **`chef_plan`** *(Chef, temp 0.7)*:运行 `ScopeCheck`;如果请求与食物无关,它会拒绝,并且整个图表会短路。否则,它会解析天数/餐数/零食数量以及任何卡路里范围,然后为**每个时段分配一道独特的菜品**(例如:*Day 1 Breakfast → Spinach & Feta Egg Scramble*)。 - **`route_after_chef`**:条件边缘。如果拒绝,它直接路由到摘要;如果在范围内,它会为每个计划的膳食发出一个 `Send("recipe_worker", RecipeTask(...))`(即 fan-out)。 - **`recipe_worker`** *(Recipe, temp 0)*:每个时段一个并行 worker。它在网络上搜索分配到的确切菜品 (Tavily),抓取页面,将每种食材在 pgvector 中进行比对,根据该餐的卡路里预算估计份量克数,并发出结构化的 `Recipe`。Workers 将结果追加到 `recipes: Annotated[list[Recipe], operator.add]`(即 fan-in)。 - **`meal_planner`** *(Planner, temp 0)*:barrier;在所有 worker 完成后运行一次。它通过缩放份量来强制执行每日卡路里上限(`_enforce_calories`,上限为 `MAX_PORTION_SCALE = 2.5`),仅要求模型提供标题和一句话介绍,然后确定性地渲染 markdown(`_render_plan_md`)并将其写入磁盘。 - **`chef_summary`**:使用保存的路径和卡路里范围(或拒绝信息)闭环。 状态存在于一个 `ChefState` Pydantic 模型中,并流经每个节点。 ## 适用人群 你熟悉 Python 并且之前见过 LLM agent;你不需要有 LangGraph 的使用经验。这个 notebook 会一步步构建图表。如果 `StateGraph`、节点和边缘对你来说是新概念,我建议你先看看 [LangGraph 快速入门](https://langchain-ai.github.io/langgraph/)。 这个 notebook 是本地优先的。它需要一个本地的 PostgreSQL 加上 pgvector 数据库。下面的快速入门只需几个命令即可让你运行起来。 ## 包含内容 | 路径 | 用途 | |------|---------| | `src/agent_guardrails.ipynb` | 全部内容:vector store、工具、guardrails、agent、图表以及自上而下的示例运行。 | | `src/common/model_factory.py` | `create_model()`:与提供商无关的 LangChain 聊天模型(Bedrock / OpenAI / Anthropic),缺少配置时快速失败。 | | `src/vectorstore/` | pgvector 后端(`build_or_load_pgvector`):幂等地构建/加载营养表。 | | `deploy/` | 用于 PostgreSQL + pgvector 的 Docker Compose (`pgvector/pgvector:pg17`)。详见 `deploy/README.md`。 | | `docs/pgvector-setup.md` | pgvector 调优说明及 AWS RDS 路径。 | | `src/data/` | OpenNutrition TSV 文件的存放位置(约 269 MB,**已 gitignored**;由 `scripts/download_data.py` 自动获取)。 | | `.env.example` | 所有环境变量均有文档说明。复制为 `.env`。 | ## 快速开始(本地) 需要 **Python 3.12** 和 [uv](https://docs.astral.sh/uv/) 包管理器。从**项目根目录**运行以下所有命令,并始终通过 `uv run` 运行,以便使用项目的 `.venv`(而不是系统/Anaconda Python)。 ``` # 安装依赖(从已提交的 lockfile) uv sync # or: ./install_deps # 配置 secrets。复制模板并填写, cp .env.example .env # 设置 LLM_PROVIDER + LLM_PROVIDER_MODEL、匹配的 provider key、 # TAVILY_API_KEY 和 DATABASE_URL(见下表) # 启动 PostgreSQL + pgvector (Docker) cd deploy && docker compose up -d && cd .. # details in deploy/README.md # 获取营养数据集(约 60 MB zip → 约 269 MB TSV 位于 src/data/ 中) # notebook 会在首次运行时自动下载;或者现在获取: uv run python scripts/download_data.py # idempotent: skips if already present # 以 headless 方式运行 notebook ... uv run --with nbconvert -- jupyter nbconvert --to notebook --execute src/agent_guardrails.ipynb # ... 或以交互方式打开 uv run --with nbconvert jupyter notebook src/agent_guardrails.ipynb ``` **首次运行会很慢,仅此一次。** 构建向量表会在 CPU 上以每批 5,000 条的速度对完整的约 326,759 种食物的数据集进行嵌入,并构建 HNSW 索引(这将需要几分钟时间)。之后的每次运行都是瞬间完成的,通过行数检查即可加载现有表,无需重新嵌入(删除表以重建)。 生成的计划将输出到 `src/meal_plans/
标签:AI智能体, DLL 劫持, LangGraph, NoSQL, Python, RAG, 大语言模型, 提示词防御, 无后门, 测试用例, 请求拦截, 逆向工具