引言:校园场景是AI Agent最真实的“修罗场”

当用户说出“帮我规划明天的校园生活”时,系统真正面对的并不是一个简单问答任务,而是一条包含查询、推理、推荐、预约和异常处理的复杂业务链路。

例如:

“我明天上午第二节没课,想找一个空研讨间做小组展示排练,排练结束后再推荐一个附近人少、味道不错的食堂,帮我把时间安排好。”

这句话至少包含以下任务:

  1. 查询课程表,确认明天空闲时间。
  2. 计算可用时间区间,并预留步行或通勤时间。
  3. 查询研讨间资源及其开放状态。
  4. 调用预约接口完成资源预订。
  5. 检索食堂高峰时段和用户偏好。
  6. 校验排练、移动和用餐时间是否冲突。
  7. 在接口失败时进行重试、降级或转人工处理。

如果仍然采用“一次Prompt加一次LLM调用”的方式,系统很容易出现以下问题:

  • 模型生成不存在的空闲时间;
  • 推荐地点距离当前场所过远;
  • 预约接口失败后流程中断;
  • 多轮对话中丢失用户的时间和偏好;
  • 用户无法知道任务执行到了哪一步。

LLM擅长理解自然语言和进行概率性推理,但它并不适合直接承担鉴权、事务执行、业务校验和异常恢复等职责。

生产级校园AI Agent的核心思想是:

让大模型负责理解和规划,让确定性代码负责执行、验证、持久化和恢复。

LangGraph提供了一个非常适合这种场景的基础:它将Agent组织为可持久化、可循环、可观测的状态图,使复杂任务不再是一次性调用,而是一个可以暂停、恢复、重试和人工接管的执行流程。


一、重新认识Agent State:它不只是消息列表

很多早期Agent只维护如下状态:

{
    "messages": []
}

这种设计适合简单聊天,却不足以支撑多步骤业务。对于校园服务来说,系统至少需要知道:

  • 用户是谁;
  • 当前会话属于哪个任务;
  • 原始需求是什么;
  • 任务已经拆解成了哪些步骤;
  • 当前执行到了哪一步;
  • 哪些工具已经调用过;
  • 工具返回了什么;
  • 哪些数据可以复用;
  • 当前是否处于重试或降级状态;
  • 最终结果是否已经生成。

1.1 建议的状态分层

一个可落地的State可以划分为六个部分:

状态域 主要内容
会话域 用户ID、会话ID、消息历史
规划域 原始需求、任务列表、当前任务游标
执行域 工具调用记录、调用参数、返回结果
缓存域 课程表、资源查询、知识库检索结果
可靠性域 重试次数、错误信息、降级等级
产出域 最终回答、终止状态

示例定义如下:

from typing import Annotated, Any, Literal, Optional
from typing_extensions import TypedDict
from langgraph.graph.message import add_messages


class ToolExecutionRecord(TypedDict, total=False):
    tool_name: str
    input_params: dict[str, Any]
    output_payload: Any
    start_time: str
    end_time: str
    status: Literal["pending", "success", "failed", "timeout"]
    error_trace: Optional[str]
    retry_count: int


class CampusAgentState(TypedDict, total=False):
    # 会话域
    user_id: str
    session_id: str
    messages: Annotated[list, add_messages]

    # 规划域
    original_user_query: str
    decomposed_tasks: list[dict[str, Any]]
    current_task_index: int

    # 执行域
    execution_trace: list[ToolExecutionRecord]
    intermediate_cache: dict[str, Any]

    # 可靠性域
    retry_count: int
    last_error: Optional[str]
    degradation_level: Literal[
        "normal",
        "fallback",
        "manual_transfer"
    ]

    # 产出域
    final_answer: str
    is_terminated: bool

这里有几个关键设计点。

第一,messages最好使用LangGraph提供的消息Reducer,而不是简单覆盖列表。这样每个节点返回的新消息可以自动追加到历史中。

第二,工具执行记录应该具有独立结构。它不仅用于调试,也可以被日志系统、审计系统和监控系统消费。

第三,时间字段建议使用字符串或标准化时间格式,而不是直接依赖Python对象。这样更方便进行Checkpoint持久化、序列化和跨服务传输。

第四,重试次数最好同时支持全局统计和任务级统计。一个失败的食堂检索任务,不应该影响其他已经成功的课程查询任务。


二、图拓扑设计:用状态机组织复杂任务

一个典型的校园Agent可以采用如下拓扑:

START
  |
Guardrail
  |
  +-- 直接回答 --------> END
  |
Planner
  |
Executor
  |
Validator
  |
  +-- 继续执行 --------> Executor
  |
  +-- 需要重试 --------> Recover
  |
  +-- 需要澄清 --------> Human/Clarification
  |
  +-- 全部完成 --------> Finalizer
                            |
                           END

其中每个节点都有明确职责:

节点 类型 职责
Guardrail 确定性 鉴权、敏感内容拦截、静态问答
Planner 概率性 将自然语言转换为结构化任务计划
Executor 确定性 根据任务类型调用工具或业务API
Validator 确定性 验证返回结果是否满足业务规则
Recover 确定性 重试、换方案、降级或人工接管
Finalizer 确定性 汇总结果并生成最终响应

LangGraph的价值不只是“把函数连起来”,更重要的是每个节点之间传递的是明确的状态。流程可以在任意节点暂停,也可以从Checkpoint恢复。


三、Guardrail:在进入LLM之前解决确定性问题

校园服务中有大量请求不需要经过大模型,例如校历、校车、考试安排和服务时间查询。

同时,涉及代考、改分、伪造证明等内容的请求,也应该由规则系统直接处理,而不是交给模型自由发挥。

async def guardrail_node(state: CampusAgentState) -> dict:
    query = state["original_user_query"]

    sensitive_keywords = ["代考", "改分", "伪造成绩"]
    if any(keyword in query for keyword in sensitive_keywords):
        return {
            "final_answer": "该问题涉及校纪校规,请联系教务处或相关管理部门咨询。",
            "degradation_level": "manual_transfer",
            "is_terminated": True,
        }

    static_answers = {
        "校历": "本学期校历请以教务系统公布的正式版本为准。",
        "校车": "校车运行时间请以校园交通服务平台的实时信息为准。",
    }

    for keyword, answer in static_answers.items():
        if keyword in query:
            return {
                "final_answer": answer,
                "is_terminated": True,
            }

    return {
        "degradation_level": "normal",
        "is_terminated": False,
    }

Guardrail通常还应该承担以下职责:

  • 校验用户身份和访问权限;
  • 检查接口调用频率;
  • 判断用户是否具备预约资格;
  • 拦截明显的提示词注入;
  • 过滤不允许写入系统的数据;
  • 判断当前服务是否处于维护时间。

原则很简单:

能通过规则确定的事情,就不要消耗模型调用。


四、Planner:让LLM负责规划,但不允许它自由发明能力

Planner的作用不是直接执行任务,而是将自然语言转成结构化计划。

例如,用户的需求可以被转换为:

{
  "tasks": [
    {
      "id": "task_1",
      "type": "query_schedule",
      "description": "查询明日上午的课程安排",
      "depends_on": []
    },
    {
      "id": "task_2",
      "type": "find_room",
      "description": "查找符合时间要求的空研讨间",
      "depends_on": ["task_1"]
    },
    {
      "id": "task_3",
      "type": "book_room",
      "description": "预约选定的研讨间",
      "depends_on": ["task_2"]
    },
    {
      "id": "task_4",
      "type": "recommend_canteen",
      "description": "推荐附近且低拥挤度的食堂",
      "depends_on": ["task_3"]
    }
  ]
}

相比简单的字符串任务列表,这种结构能够表达:

  • 任务类型;
  • 任务依赖;
  • 工具名称;
  • 输入参数;
  • 是否需要用户确认;
  • 失败后的备用策略。

可以使用Pydantic约束Planner输出:

from pydantic import BaseModel, Field
from typing import Literal


class PlannedTask(BaseModel):
    task_id: str
    task_type: Literal[
        "query_schedule",
        "find_room",
        "book_room",
        "recommend_canteen",
        "clarify"
    ]
    description: str
    depends_on: list[str] = Field(default_factory=list)
    requires_confirmation: bool = False


class TaskPlan(BaseModel):
    tasks: list[PlannedTask]
    clarification_question: str | None = None

Planner节点:

async def planner_node(state: CampusAgentState) -> dict:
    llm = get_planner_model()
    structured_llm = llm.with_structured_output(TaskPlan)

    prompt = f"""
你是校园生活服务规划器。

只允许使用以下任务类型:
- query_schedule
- find_room
- book_room
- recommend_canteen
- clarify

不要创造新的工具,不要直接执行预约。
如果信息不足,生成clarify任务。

用户需求:
{state["original_user_query"]}
"""

    plan: TaskPlan = await structured_llm.ainvoke(prompt)

    return {
        "decomposed_tasks": [
            task.model_dump()
            for task in plan.tasks
        ],
        "current_task_index": 0,
        "intermediate_cache": {
            "clarification_question": plan.clarification_question
        },
        "last_error": None,
    }

生产环境中,还应在Planner之后增加计划校验器,检查:

  • 任务类型是否在白名单内;
  • 任务依赖是否形成循环;
  • 预约类任务是否包含必要参数;
  • 是否存在未经授权的写操作;
  • 任务数量是否超过上限;
  • 是否需要用户二次确认。

五、规则优先:对抗规划漂移

大模型即使输出符合JSON格式,也不代表计划一定正确。格式正确与业务正确是两件事。

对于高频且稳定的意图,可以先使用规则引擎:

async def robust_planner(state: CampusAgentState) -> dict:
    query = state["original_user_query"]

    if "课表" in query and "空闲" in query:
        tasks = [
            {
                "task_id": "schedule",
                "task_type": "query_schedule",
                "description": "查询指定日期的课程安排",
                "depends_on": [],
            },
            {
                "task_id": "free_time",
                "task_type": "find_room",
                "description": "基于空闲时间查找可用研讨间",
                "depends_on": ["schedule"],
            },
        ]
        return {
            "decomposed_tasks": tasks,
            "current_task_index": 0,
        }

    return await planner_node(state)

实际系统可以采用“快慢双轨”策略:

  1. 规则引擎处理高频、明确和低风险意图。
  2. 小模型处理常见但需要轻度理解的请求。
  3. 大模型处理复杂、多步骤和模糊需求。
  4. 所有计划最终都必须经过Schema和业务规则校验。

这套策略可以降低延迟和Token成本,也能减少模型在常见任务上的不必要发挥。


六、Executor:用工具注册表代替字符串判断

简单示例中常见这样的写法:

tool_name = "query_schedule" if "课表" in task_desc else "book_resource"

这种逻辑很脆弱。只要任务描述稍有变化,就可能路由到错误工具。

更稳妥的方式是使用结构化任务类型和工具注册表:

TOOL_REGISTRY = {
    "query_schedule": query_schedule,
    "find_room": find_available_rooms,
    "book_room": book_room,
    "recommend_canteen": recommend_canteen,
}

Executor只根据task_type选择工具:

async def executor_node(state: CampusAgentState) -> dict:
    index = state["current_task_index"]
    tasks = state["decomposed_tasks"]

    if index >= len(tasks):
        return {"is_terminated": True}

    task = tasks[index]
    task_type = task["task_type"]
    tool = TOOL_REGISTRY.get(task_type)

    if tool is None:
        return {
            "last_error": f"未注册的任务类型:{task_type}",
            "degradation_level": "manual_transfer",
        }

    try:
        result = await call_with_timeout(
            tool,
            task,
            timeout_seconds=3
        )

        return {
            "intermediate_cache": {
                **state.get("intermediate_cache", {}),
                task["task_id"]: result,
            },
            "last_error": None,
        }

    except TimeoutError:
        return {
            "last_error": "工具调用超时",
        }

    except Exception as exc:
        return {
            "last_error": str(exc),
        }

这里需要注意一个重要问题:工具调用与状态更新最好具有清晰的事务边界。

对于查询类操作,可以安全重试;对于预约、取消预约、缴费等写操作,则必须考虑幂等性。否则,第一次请求可能已经成功,只是响应丢失,第二次重试就可能产生重复预约。

解决方式包括:

  • 为每次写操作生成幂等键;
  • 在调用前检查是否已经存在相同业务请求;
  • 对接口返回结果进行最终一致性查询;
  • 区分“请求未发出”“请求已发出但超时”和“明确业务失败”。

七、Validator:HTTP成功不等于业务成功

很多系统只判断HTTP状态码:

if response.status_code == 200:
    return "success"

但校园系统经常出现以下情况:

{
  "code": 0,
  "message": "success",
  "data": null
}

这在协议层面可能是成功,在业务层面却完全不可用。

因此,Validator必须验证返回数据是否满足业务约束。

def validate_tool_result(
    task_type: str,
    result: dict
) -> tuple[bool, str | None]:

    if result.get("code") != 0:
        return False, result.get("message", "接口返回失败")

    data = result.get("data")

    if task_type == "query_schedule":
        if not data or not isinstance(data.get("courses"), list):
            return False, "课程表数据为空或格式错误"

    if task_type == "find_room":
        rooms = data.get("rooms", []) if data else []
        if not isinstance(rooms, list):
            return False, "研讨间列表格式错误"

    if task_type == "book_room":
        if not data or not data.get("reservation_id"):
            return False, "预约成功但缺少预约编号"

    return True, None

Validator还可以检查现实世界中的约束:

  • 预约开始时间必须早于结束时间;
  • 资源容量必须满足小组人数;
  • 房间必须处于开放状态;
  • 食堂距离不能超过可接受范围;
  • 推荐时间不能与课程或预约冲突;
  • 返回的预约时间不能落在系统维护窗口内。

这一步是杜绝“表面成功”的关键。


八、条件路由:重试、恢复和降级

LangGraph允许根据当前State选择下一条边。一个简单的路由函数如下:

from typing import Literal


def route_after_validation(
    state: CampusAgentState
) -> Literal["executor", "recover", "finalizer"]:

    if state.get("last_error"):
        retry_count = state.get("retry_count", 0)

        if retry_count < 2:
            return "recover"

        return "recover"

    index = state["current_task_index"]
    total = len(state["decomposed_tasks"])

    if index + 1 >= total:
        return "finalizer"

    return "executor"

恢复节点不应该只做一件事:重复调用同一个接口。

更合理的恢复策略包括:

  1. 使用指数退避重试;
  2. 缩小查询范围;
  3. 切换备用接口;
  4. 使用缓存结果;
  5. 跳过非关键任务;
  6. 请求用户补充信息;
  7. 转人工处理。

例如:

async def recover_node(state: CampusAgentState) -> dict:
    retry_count = state.get("retry_count", 0) + 1
    error = state.get("last_error", "")

    if "超时" in error and retry_count <= 2:
        return {
            "retry_count": retry_count,
            "last_error": None,
        }

    if "没有可用研讨间" in error:
        return {
            "degradation_level": "fallback",
            "last_error": None,
            "intermediate_cache": {
                **state.get("intermediate_cache", {}),
                "fallback_message": "当前没有符合条件的研讨间,可尝试调整时间或地点。",
            },
        }

    return {
        "degradation_level": "manual_transfer",
        "final_answer": "当前预约服务暂时不可用,请稍后重试或联系服务台。",
        "is_terminated": True,
    }

需要特别注意,重试计数不能只使用一个全局变量。更好的做法是:

{
    "retry_count": 1,
    "task_retry_count": {
        "query_schedule": 0,
        "book_room": 1
    }
}

这样可以避免某个失败任务消耗掉整个流程的重试额度。


九、构建LangGraph工作流

将上述节点连接起来:

from langgraph.graph import StateGraph, START, END


builder = StateGraph(CampusAgentState)

builder.add_node("guardrail", guardrail_node)
builder.add_node("planner", robust_planner)
builder.add_node("executor", executor_node)
builder.add_node("validator", validator_node)
builder.add_node("recover", recover_node)
builder.add_node("finalizer", finalizer_node)

builder.add_edge(START, "guardrail")

builder.add_conditional_edges(
    "guardrail",
    route_after_guardrail,
    {
        "planner": "planner",
        "end": END,
    }
)

builder.add_edge("planner", "executor")
builder.add_edge("executor", "validator")

builder.add_conditional_edges(
    "validator",
    route_after_validation,
    {
        "executor": "executor",
        "recover": "recover",
        "finalizer": "finalizer",
    }
)

builder.add_conditional_edges(
    "recover",
    route_after_recover,
    {
        "executor": "executor",
        "finalizer": "finalizer",
        "end": END,
    }
)

builder.add_edge("finalizer", END)

graph = builder.compile()

真正上线时,还应配置Checkpoint存储,使流程能够在以下情况下恢复:

  • 服务进程重启;
  • 工具调用需要人工确认;
  • 用户暂时离开;
  • 预约操作等待异步回调;
  • 多步骤任务执行时间较长。

Checkpoint的本质,是把Agent从“一次函数调用”升级为“可恢复业务流程”。


十、记忆管理:解决长对话中的上下文丢失

校园助手往往需要处理多轮对话:

“帮我找明天下午的研讨间。”

“人数是六个人。”

“不要太远,最好靠近东区。”

“预约前先告诉我具体时间。”

如果每轮都携带完整历史,Token成本会持续增加;如果简单截断,又容易丢失关键条件。

可以采用分层记忆:

短期记忆

保存当前任务直接相关的消息,例如:

  • 当前日期;
  • 人数;
  • 地点偏好;
  • 时间限制;
  • 用户最近一次确认。

摘要记忆

将较早的对话压缩成结构化摘要:

{
  "user_preferences": {
    "preferred_zone": "东区",
    "max_walking_minutes": 10,
    "group_size": 6
  },
  "confirmed_constraints": [
    "预约前必须先展示时间和地点",
    "不接受跨校区资源"
  ]
}

长期记忆

经过用户授权后,保存稳定偏好,例如常用校区、饮食偏好和常用资源。

记忆系统必须明确区分:

  • 用户本轮临时要求;
  • 已确认的长期偏好;
  • 推测出来但尚未确认的信息。

未经用户确认的推测,不应直接作为预约参数。


十一、事务性操作:预约前必须确认,写入后必须核验

查询和推荐通常是低风险操作,预约、取消、缴费等则属于高风险写操作。

对于这类操作,建议采用如下流程:

生成预约方案
    |
展示房间、时间、地点和费用
    |
请求用户确认
    |
生成幂等键
    |
调用预约接口
    |
查询最终预约状态
    |
返回预约编号

不能仅凭接口超时就认为预约失败,也不能仅凭HTTP 200就认为预约成功。

正确的判断应当包含:

  • 是否生成业务订单号;
  • 是否返回资源编号;
  • 是否查询到最终状态;
  • 是否与用户确认的参数一致;
  • 是否在重复请求中保持幂等。

这也是Agent系统区别于普通聊天机器人的重要地方:它必须对现实世界中的状态变化负责。


十二、全链路可观测性:让每一次失败都能被解释

没有观测数据,就无法知道Agent为什么失败。

建议为每次任务生成唯一的:

  • request_id
  • session_id
  • trace_id
  • task_id
  • tool_call_id

并记录:

  • 节点开始和结束时间;
  • 模型耗时和Token消耗;
  • 工具名称和参数摘要;
  • 外部接口耗时;
  • 重试次数;
  • 校验失败原因;
  • 降级等级;
  • 最终用户可见结果。

OpenTelemetry示例:

from opentelemetry import trace

tracer = trace.get_tracer("campus.agent")


async def traced_executor(state: CampusAgentState) -> dict:
    task = state["decomposed_tasks"][
        state["current_task_index"]
    ]

    with tracer.start_as_current_span("agent.executor") as span:
        span.set_attribute("task.type", task["task_type"])
        span.set_attribute("task.id", task["task_id"])

        try:
            result = await executor_node(state)
            span.set_attribute("result.status", "success")
            return result

        except Exception as exc:
            span.record_exception(exc)
            span.set_attribute("result.status", "failed")
            raise

监控指标可以包括:

  • 任务整体成功率;
  • 各工具成功率;
  • 平均执行时长;
  • P95和P99延迟;
  • Planner计划校验失败率;
  • Validator业务失败率;
  • 重试率;
  • 人工接管率;
  • 单次任务平均成本。

例如,如果发现预约接口在每天21点后的失败率明显升高,就可以增加服务时间前置校验,而不是让用户反复等待重试。


十三、安全边界:状态管理也必须遵循最小权限

校园Agent通常会接触课程表、预约记录、联系方式甚至缴费信息,因此不能只关注流程正确性,还必须关注数据安全。

建议遵循以下原则:

最小权限

不同工具使用不同权限:

  • 查询课程表只能读取本人数据;
  • 查询房间可以读取公共资源;
  • 预约资源需要额外写权限;
  • 取消预约需要二次确认。

敏感数据脱敏

日志中不应直接记录:

  • 身份证号码;
  • 完整手机号;
  • 访问令牌;
  • 详细个人隐私;
  • 未经授权的课程信息。

工具参数白名单

不允许模型直接拼接任意URL、SQL或系统命令。所有工具都应由服务端注册,并对参数进行类型和范围校验。

用户确认

涉及以下操作时,必须让用户明确确认:

  • 预约;
  • 取消;
  • 缴费;
  • 提交材料;
  • 修改个人信息;
  • 向第三方发送信息。

十四、生产落地检查清单

在上线前,可以从以下几个方面进行验收:

状态设计

  • State是否可以完整描述任务进度?
  • 状态是否可以序列化和恢复?
  • 是否区分临时数据、缓存数据和长期记忆?
  • 是否记录了任务级执行轨迹?

规划可靠性

  • Planner是否使用结构化输出?
  • 工具和任务类型是否有白名单?
  • 是否校验任务依赖和参数完整性?
  • 是否有规则引擎处理高频意图?

工具执行

  • 查询和写操作是否区分?
  • 写操作是否具备幂等能力?
  • 是否有超时、重试和备用接口?
  • 是否对业务返回结果进行校验?

异常恢复

  • 是否支持任务级重试?
  • 是否能够识别可恢复和不可恢复错误?
  • 是否提供明确的降级结果?
  • 是否支持人工接管?

可观测性

  • 是否可以根据Trace还原完整流程?
  • 是否记录了模型、工具和业务校验耗时?
  • 是否能够定位失败节点?
  • 是否建立了成功率、延迟和成本指标?

用户体验

  • 是否会在高风险写操作前请求确认?
  • 是否能告诉用户当前执行进度?
  • 是否能解释失败原因?
  • 是否避免把内部异常堆栈直接展示给用户?

结语:Agent的核心竞争力是确定性兜底

校园AI Agent的难点,从来不只是如何调用一个更强的大模型,而是如何让模型参与复杂业务时仍然保持可控、可解释和可恢复。

一套可靠的校园Agent应当遵循三条原则:

  1. 能通过规则解决的问题,不交给LLM。
  2. 能通过确定性校验确认的结果,不相信模型的自我判断。
  3. 任何可能改变现实状态的操作,都必须具备权限控制、幂等机制和结果核验。

LangGraph的价值,正在于它把这些工程原则落实为可执行的状态图:Guardrail负责拦截,Planner负责规划,Executor负责执行,Validator负责验证,Recover负责恢复,Checkpoint负责持久化,Trace负责解释全过程。

在这套架构中,大模型不再是一个不可控的黑盒,而是被放置在明确的边界之内。它负责理解复杂需求、生成候选计划和组织自然语言;而真正涉及权限、数据、事务和现实世界状态的部分,则由确定性的工程代码接管。

这才是校园AI Agent走向生产环境的关键:

用图结构管理复杂流程,用强类型State承载上下文,用业务规则约束模型,用可观测性保障结果,用确定性工程驾驭概率性智能。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐