Agent Planner输出不是合法JSON怎么办?Schema约束、提取、校验与自动修复

文章摘要

Plan-and-Execute架构依赖Planner生成结构化计划,但模型常出现Markdown代码块、字段缺失、依赖不存在、步骤ID重复或循环依赖。本文给出一套生产级处理链:明确JSON Schema、提取JSON、Pydantic校验、DAG检查、错误反馈修复和失败降级,并提供可直接复用的Python代码。

一、为什么Planner比普通问答更怕格式错误

普通问答多一个标点通常没有影响。

Planner输出却要被程序直接执行:

{
  "steps": [
    {
      "id": "S1",
      "task": "查询订单",
      "dependencies": []
    }
  ]
}

如果出现以下问题,执行器就可能失败:

  • 输出Markdown代码块;
  • JSON前后有解释文字;
  • 使用单引号;
  • 字段名称变化;
  • steps不是数组;
  • 步骤ID重复;
  • 依赖不存在;
  • 循环依赖;
  • 工具名称不存在;
  • 步骤数量失控。

所以Planner必须经过:

格式解析
→ Schema校验
→ 语义校验
→ 图结构校验
→ 安全校验

二、先定义稳定的数据模型

使用Pydantic 2:

from pydantic import BaseModel, Field
from typing import Literal


class PlanStep(BaseModel):
    id: str = Field(pattern=r"^S[1-9][0-9]*$")
    title: str = Field(min_length=1, max_length=80)
    description: str = Field(min_length=1, max_length=500)
    dependencies: list[str] = Field(default_factory=list)
    tool_hint: str | None = None
    expected_output: str = Field(min_length=1, max_length=300)
    risk_level: Literal["low", "medium", "high"] = "low"


class TaskPlan(BaseModel):
    objective: str = Field(min_length=1, max_length=500)
    assumptions: list[str] = Field(default_factory=list)
    steps: list[PlanStep] = Field(min_length=1, max_length=20)
    completion_criteria: list[str] = Field(min_length=1)

Schema本身就限制:

  • ID格式;
  • 字段长度;
  • 步骤数量;
  • 风险等级枚举;
  • 必填字段。

三、Prompt中给出严格输出协议

你是任务规划器。

只输出一个JSON对象,不要输出Markdown代码块、解释、前言或结尾。

输出必须符合以下规则:
1. steps数量为1到20。
2. 步骤ID使用S1、S2、S3格式。
3. dependencies只能引用已经存在的步骤ID。
4. 不允许循环依赖。
5. 每个步骤必须可以独立判断是否完成。
6. 高风险动作标记risk_level=high。
7. 不得生成未注册工具名称。

Prompt可以降低错误率,但不能替代程序校验。

四、提取JSON对象

模型可能返回:

以下是计划:
```json
{...}
可以先去除代码围栏:

```python
import re

def strip_markdown_fence(text: str) -> str:
    text = text.strip()

    fence = re.fullmatch(
        r"```(?:json)?\s*(.*?)\s*```",
        text,
        flags=re.DOTALL | re.IGNORECASE
    )

    if fence:
        return fence.group(1).strip()

    return text

如果前后还有文字,可以提取第一个完整JSON对象。

推荐使用字符状态机,而不是简单正则:

def extract_first_json_object(text: str) -> str:
    start = text.find("{")
    if start  TaskPlan:
    clean = strip_markdown_fence(raw_text)
    json_text = extract_first_json_object(clean)

    try:
        data = json.loads(json_text)
    except json.JSONDecodeError as exc:
        raise ValueError(
            f"JSON解析失败:line={exc.lineno}, col={exc.colno}, msg={exc.msg}"
        ) from exc

    try:
        return TaskPlan.model_validate(data)
    except ValidationError as exc:
        raise ValueError(
            f"计划Schema校验失败:{exc.errors()}"
        ) from exc

不要自动把单引号替换成双引号,因为:

  • 字符串内部可能包含单引号;
  • 错误修复可能改变原意;
  • 容易掩盖模型输出质量问题。

六、校验步骤ID与依赖

def validate_dependencies(plan: TaskPlan) -> None:
    step_ids = [step.id for step in plan.steps]

    if len(step_ids) != len(set(step_ids)):
        raise ValueError("存在重复步骤ID")

    known = set(step_ids)

    for step in plan.steps:
        if step.id in step.dependencies:
            raise ValueError(
                f"步骤{step.id}不能依赖自己"
            )

        unknown = set(step.dependencies) - known

        if unknown:
            raise ValueError(
                f"步骤{step.id}引用未知依赖:{sorted(unknown)}"
            )

还可以要求依赖只能指向前面的步骤:

def validate_dependency_order(plan: TaskPlan) -> None:
    positions = {
        step.id: index
        for index, step in enumerate(plan.steps)
    }

    for step in plan.steps:
        for dependency in step.dependencies:
            if positions[dependency] >= positions[step.id]:
                raise ValueError(
                    f"{step.id}依赖了未提前定义的{dependency}"
                )

如果执行器支持真正DAG,不强制顺序也可以,但必须做循环检测。

七、检测循环依赖

使用Kahn算法:

from collections import defaultdict, deque

def validate_acyclic(plan: TaskPlan) -> None:
    graph: dict[str, list[str]] = defaultdict(list)
    indegree: dict[str, int] = {
        step.id: 0 for step in plan.steps
    }

    for step in plan.steps:
        for dependency in step.dependencies:
            graph[dependency].append(step.id)
            indegree[step.id] += 1

    queue = deque(
        step_id
        for step_id, degree in indegree.items()
        if degree == 0
    )

    visited = 0

    while queue:
        current = queue.popleft()
        visited += 1

        for next_step in graph[current]:
            indegree[next_step] -= 1

            if indegree[next_step] == 0:
                queue.append(next_step)

    if visited != len(plan.steps):
        raise ValueError("计划中存在循环依赖")

典型错误:

S1依赖S3
S2依赖S1
S3依赖S2

格式完全合法,但永远无法开始执行。

八、校验工具白名单

REGISTERED_TOOLS = {
    "search_web",
    "query_order",
    "query_inventory",
    "generate_report"
}

def validate_tools(plan: TaskPlan) -> None:
    for step in plan.steps:
        if (
            step.tool_hint is not None
            and step.tool_hint not in REGISTERED_TOOLS
        ):
            raise ValueError(
                f"步骤{step.id}使用未注册工具:{step.tool_hint}"
            )

Planner不能凭空创造不存在的工具,执行器必须只接受已注册工具。

九、把错误反馈给模型修复

首次失败后,不要简单重复原Prompt。

应提供明确校验错误:

def build_repair_prompt(
    original_request: str,
    invalid_output: str,
    error_message: str
) -> str:
    return f"""
你上一次生成的任务计划无法通过校验。

用户任务:
{original_request}

校验错误:
{error_message}

原始输出:
{invalid_output}

请修复计划。
只输出完整JSON对象,不要输出解释或Markdown代码块。
"""

修复最多尝试1—2次:

def generate_valid_plan(client, user_request: str) -> TaskPlan:
    raw = client.complete(build_plan_prompt(user_request))

    for attempt in range(2):
        try:
            plan = parse_plan(raw)
            validate_dependencies(plan)
            validate_acyclic(plan)
            validate_tools(plan)
            return plan
        except ValueError as exc:
            if attempt == 1:
                raise

            raw = client.complete(
                build_repair_prompt(
                    user_request,
                    raw,
                    str(exc)
                )
            )

    raise RuntimeError("无法生成有效计划")

十、不要无限修复

如果连续失败,应降级:

1. 使用更强模型重试一次
2. 切换到简单单步计划
3. 请求用户补充信息
4. 转人工处理

建议记录:

planner_model
attempt_count
validation_error
raw_output_hash
repair_success

十一、结构合法还不够

以下计划Schema正确,但没有执行价值:

{
  "steps": [
    {
      "id": "S1",
      "title": "完成任务",
      "description": "把任务完成",
      "dependencies": [],
      "expected_output": "任务完成",
      "risk_level": "low"
    }
  ]
}

需要语义规则:

  • 步骤必须可执行;
  • 不能简单重复用户目标;
  • expected_output必须可验证;
  • 每一步粒度适中;
  • 不得包含未提供的敏感信息;
  • 高风险步骤必须标记。

可以增加Reviewer模型,但最终安全边界仍由规则控制。

十二、生产级处理链

模型生成
→ 去除围栏
→ 提取JSON
→ JSON解析
→ Pydantic校验
→ ID与依赖校验
→ DAG循环检测
→ 工具白名单
→ 风险规则
→ 自动修复一次
→ 降级或人工

总结

Planner输出不是合法JSON,不能只靠一句“请严格输出JSON”解决。

可靠方案必须同时具备:

明确Schema
+解析器
+类型校验
+图结构校验
+工具白名单
+有限修复
+失败降级

只有经过校验的计划,才能进入执行器。