Skip to content

16 综合实战:Trip Assistant 旅行管家(全章收官) ​

前面十五节,零件是分开学的:状态图、条件路由、Send 并行、流式调试、Checkpointer、Store、RetryPolicy、子图、interrupt()、Functional API。这一节不再看零件柜,而是把它们装进一台能在浏览器里值班的旅行工作台。

这台工作台干的是国内旅行这摊事:国内攻略问答,加上订机票、订酒店、订租车、订景点门票。可以把它想成一家国内旅行平台的客服大厅,而不是聊天机器人:进门先登录(账号密码 + 图形验证码),进来后左侧一条导航切页——对话(工具过程收在思考里,正文 Markdown 流式出来,写操作在这里签字)、行程(真实底图 + 去程 / 回程航段)、探索(挑城市直接翻库存)、看板(一整块出发信息板)、订单、运维。敏感操作像退票、订车、改酒店——必须有人在签字柜前点头,图才会继续往前走。

对话列顶部还有一条会话条:点一下切换、✎ 改名、× 删除、+ 新对话 开一条。对话存档是落盘的,关掉服务再起来,历史还在。空草稿不堆积的规矩见 7.2。

代码位置:完整项目在 code/travel_agent_v2/。工作台默认接 .env 里的真实模型;假模型三层测试仍然零 Key 可跑,用来锁住控制流。


1. 为什么这一节必须做成整机,而不是又一个 Demo ​

分节示例像驾校场地:每次只练一项,油门、刹车、倒库分开考。真实客服不是这样上班的。旅客一句话里常常叠了三件事:

“帮我把下周二飞成都的机票退了,改签到周三,顺便订一家春熙路附近的酒店和一辆带儿童座椅的 SUV。”

如果还靠“一个大 Prompt + while 循环”,模型可能先改签、再订车、中途把退票也做了,你事后才在账单里看见。工作台要解决的是另一件事:过程必须看得见、写操作必须停得住、会话挂了必须接得上、进来的人必须认得出来、花过的钱必须查得到。

所以这一节的验收标准不是“模型能聊天”,而是:

  1. 谁在说话由登录账号决定:JWT 会话绑定 passenger_id,换账号才换行程、对话、订单和档案;
  2. 主助理只负责分诊,真正改库存的是专员;
  3. 敏感工具在 interrupt() 闸门停住,批准才执行,驳回必须补齐 ToolMessage;
  4. 租车走真子图,中断能穿透到父图;
  5. 比价用 Send 同时扇出四路,汇总后再回话;
  6. 会话要能续:对话存档落盘,服务重启历史还在,能新建 / 切换 / 改名 / 删除;
  7. 订单要留痕:订了、退了、改期了,订单中心一笔一笔都在,取消一单库存立刻放开;
  8. 谁在说话要认得出:role=ops 的运营账号能看到全量订单与审计流水,普通账号只看得到自己的;
  9. 门要挡得住脚本:注册过图形验证码(一题一用、限时限次),登录连错 3 次就加码。

定位先说清:数据集和提示词都是国内旅行——19 个国内机场、约 3400 个国内航班、24 家国内酒店、16 家租车品牌、38 个国内景点玩法;演示旅客四份(alice 北京↔成都、bob 上海↔三亚、carol 广州↔西安、ops 杭州↔青岛),运营账号有自己的档案,不和 carol 合住。FAQ 也整体重写成国内政策与攻略(行李额、退改签、租车押金与异地还车、景区预约、高原反应……)。旧那套国际航线数据是早期从 Postgres 示例库搬来的,已经备份到 db/travel2_intl_backup.sqlite,不再参与运行。

Trip Assistant:主助理分诊,专员平铺或走真子图,敏感操作进审批闸门


2. 生活比喻:前台、专员与签字柜 ​

把这台系统想成一家国内旅行平台的客服大厅(北京、成都、三亚这些航段就贴在大厅的行程图上):

现场角色图里的零件对应小节
门口输账号密码进大厅登录认证:账号密码换一枚 JWT 通行证本节
门口先答一道题,才准取号图形验证码(防脚本;一题一用)本节
通行证上印着谁的会员号令牌里的 passenger_id 决定读谁的档案与行程本节 / 06 / 10
旅客窗口说一句话messages 进图02
前台先调会员资料fetch_user_info 读旅客档案02 / 11
前台按需求指路主助理 + 转交工具 + 条件路由03 / 07
机票 / 酒店 / 门票三个柜台平铺在父图里的专员07 / 08
租车部在另一层楼真子图 book_car_rental12
一次问四类行情Send 四路查询 + aggregate04
退票、改期、订车这种动库存的动作interrupt() 把工单塞进签字柜13
当天的对话本Checkpointer + thread_id06 / 10
会员卡上的“靠窗”Store + InjectedStore10
柜台上那叠成交单据订单中心:orders 表本节
大厅角落的办事流水册审计流水:audit 表本节
只读查询偶发超时RetryPolicy(写节点绝不重试)11
大厅墙上那排流程灯Web 工作台:平铺航段、智能体流程、库存看板05 / 16

一句话:程序员铺轨道和闸门,模型只在路口做分诊。 这就是全章反复说的那句——可控性,才是 LangGraph 相对“一次性放手执行 Agent”的价值。


3. 项目怎么分层 ​

进入 code/travel_agent_v2,按生产系统的思路拆,但依赖刻意保持本地。

分层的判据只有一条:谁认识谁。上层可以叫下层,下层永远不知道上层存在;把这条守住,改动半径就能算出来。

层目录管什么不认识谁
① 智能体agent/图本身:状态、主助理与专员的提示词与节点、拼装(平铺 / 真子图 / Send / 审批闸门)、喂模型的上下文窗口不认识 web、不认识 HTTP
② 工具tools/业务读写(航班 / 酒店 / 租车 / 景点)、记忆工具、政策检索、ToolNode 容错不认识图、不认识界面
③ 记忆memory/跨会话的长期记忆(Store,落 db/preferences.sqlite)不认识图与界面
④ 基础设施infra/日志、模型入口、业务库路径与日期平移、一次性数据脚本不认识上面任何一层
⑤ 接口与展示web/HTTP 路由、运行时(身份 / 快照 / 推进 / SSE)、账号与三本账、页面与图形只依赖 ①–④
组合根main.py唯一知道「怎么把 ①–④ 装成一张图」的地方——

文件级目录以 code/travel_agent_v2/README.md 为准,这里只记分层的判据:改这里会不会牵动那里。业务库换位置只动 infra/paths.py,换模型网关只动 infra/llm.py,加专员只填 agent/graph.py 的注册表,换界面只动 web/present/。

这几条规矩有测试盯着:tests/test_layering.py 静态扫全仓(按值 import 重绑定名、paths.py 之外的 __file__、写死的 .sqlite 文件名、内层 import 外层、注释里的旧路径)。只读源码、零 Key,改完先跑它。

3.1 为什么要把「应用库」和「业务库」分开 ​

项目里的数据其实是两种东西,脾气完全不同:

业务库(机场 / 航班 / 酒店 / 租车 / 景点)应用库(账号 / 会话 / 订单 / 审计)
文件db/travel_new.sqlitedb/app.sqlite
谁来写种子脚本 + 图里的业务工具登录、下单、审计这些应用逻辑
每次启动被种子库整份覆盖(update_dates())原样保留
丢掉会怎样无所谓,下一秒重建,还是干净数据账号登不进、订单查不到、流水没了
定位可重置的演示道具用户资产

把账号和订单写进业务库,程序也能跑一天——直到重启一次,全没了。所以这一节新开了 app.sqlite,业务库照旧重建,应用库一次写入永久保留。这条线一划清,后面「订单为什么不能跟库存放一起」「booked 为什么只是投影」就都好解释了(见第 7 节)。


4. 知识对号入座 ​

工作台里能看见的用到的知识点对应小节
登录页输账号密码口令哈希校验(PBKDF2 + 盐)本节
登录前那一道图形验证码一次性凭据、答案留服务端、一题一用本节
左侧「运维」页图状态(栈 / 档案 / 时间线),对谁都开;全量账本在订单页的审计区本节
信号灯与等宽数据列界面把状态和分组编码进结构(设计令牌)本节
通行证 Cookie 算不算数JWT 验签(写死算法)+ jti 吊销名单本节
ops 才能拿到全量审计role 字段 + 接口层 403本节
换账号后行程、对话、订单全换按账号隔离配置与会话本节 / 06 / 10
对话列顶部的会话条会话持久化(SqliteSaver + 自己的 sessions 表)本节 / 06
订单中心一笔笔成交单据运行时按工具名记账 + 库存回写本节
大厅角落的流水册审计留痕与角色权限本节
State + dialog_state 状态栈TypedDict、自定义 reducer02 / 07
主助理的转交工具(含 ToMultiQuote)Pydantic 意图 + 条件路由03 / 07
专员内部的 tools_condition + 工具节点ReAct 工具循环08
create_tool_node_with_fallbackToolNode 容错08
安全工具 / 敏感工具分池Routing 模式03 / 09
前台把旅客转到专员Handoffs07 / 12
租车部真子图Subgraph 封装,中断向上穿透12
四路行情同时亮灯Send Map-Reduce04
右侧“靠窗 / 常旅客号”Store + InjectedStore10
只读节点挂 RetryPolicy持久执行;写节点不重试11
琥珀色审批卡片interrupt() + Command(resume)13
换会话后对话清空、档案还在Checkpointer vs Store06 / 10
关掉服务再打开,历史还在SqliteSaver 落盘 Checkpointer06 / 10
行程卡片平铺、流程节点按站亮灯stream_mode=["updates","messages"]05
工具回执长出的航班 / 酒店 / 景点卡片工具回执 → 结构化卡片(生成式 UI:渲染层还原,ast.literal_eval 而不是 eval)本节
行程页那张航图真实底图 + 三层符号(省会底点 / 可订城市 / 我的航段)与法线方向归一化本节
审批卡上的动作 / 对象 / 日期 / 金额审计流水 + 审批字段化(interrupt 的 payload 由前端渲染)本节 / 13
update_dates()工程细节:让“明天”永远可查本节

对照着改造练手:酒店助理仍是平铺结构。可以把它收成第二个真子图,或给审批包加上金额阈值、参数编辑、多级签字;登录可以换成真的 OIDC / SSO,把口令哈希换 Argon2id,把 jti 吊销名单挪到 Redis。


5. 登录认证:先认人,再开门 ​

前面的小节都在讲图内部怎么跑,但一放到浏览器里就露出一个洞:谁在跟这台机器说话? 早期版本把旅客号写死在配置里,谁打开页面都是同一位旅客。这在小 demo 里无所谓,一旦有两个浏览器窗口,两个人的对话就会掉进同一个抽屉。

所以这一节要补的不是花活,是一套正经的账号体系:注册、登录、退出,加上把「谁在说话」绑到每个请求上。打开工作台,第一眼就是这张卡片——左边一句话说明,右边登录 / 注册两个页签,演示账号点一下自动填好。

登录页:左说明右表单,四个演示账号点一下自动填好

5.1 口令怎么存,比怎么发令牌更重要 ​

新手最常写的一版是明文存密码,或者存一次 MD5。两者都不行:库被拖走等于密码全泄露,而 MD5 早就被查表穷举穿了。正确做法是加盐慢哈希:

python
PBKDF2_ROUNDS = 260_000          # OWASP 对 PBKDF2-SHA256 的建议量级

def hash_password(password: str, salt: str | None = None) -> tuple[str, str]:
    salt = salt or secrets.token_hex(16)          # 每个账号独立的盐
    digest = hashlib.pbkdf2_hmac(
        "sha256", password.encode("utf-8"), bytes.fromhex(salt), PBKDF2_ROUNDS
    )
    return salt, digest.hex()

三个要点:盐要每个账号各不相同(否则两个人同密码哈希就一样,一张彩虹表打穿全表);迭代次数要够高(把暴力破解的成本抬起来);比较要用常量时间函数:

python
def verify_password(password: str, salt: str, expected: str) -> bool:
    _, actual = hash_password(password, salt)
    return hmac.compare_digest(actual, expected)   # 别用 ==,会泄露计时信息

还有一条容易被忽略的:用户名不存在和密码错误要返回同一个错误。如果提示「该用户不存在」,等于免费送给攻击者一个枚举账号的接口。

5.2 JWT 里装什么 ​

校验通过后签发 HS256 的 JWT,声明装的是「这个人是谁」,不是「他能干什么」:

python
claims = {
    "sub": user["username"],              # 登录名
    "uid": user["id"],
    "name": user["display_name"],
    "role": user.get("role") or "passenger",  # passenger | ops,决定能不能看全量流水
    "passenger_id": user["passenger_id"], # 绑定的旅客档案,后续读数据用它
    "iss": ISSUER,
    "iat": now,
    "exp": now + 12 * 3600,               # 12 小时到点自动失效
    "jti": str(uuid.uuid4()),             # 这次令牌的唯一编号,退出登录要用
}

浏览器侧只拿到一枚 HttpOnly Cookie,JavaScript 读不到令牌内容——这是 XSS 拿不走会话的第一道护栏。服务端校验时把算法、签发方、有效期一起核对:

python
claims = jwt.decode(
    token, secret(),
    algorithms=[ALGORITHM],      # 写死算法:否则把头部改成 alg=none 就能伪造令牌
    issuer=ISSUER,
    options={"require": ["exp", "iat", "sub", "jti"]},
)

algorithms=["HS256"] 不是可选项。JWT 最经典的洞就是算法混淆——头部声明用什么算法,库就信什么,于是 alg: none 或把 RS256 换成 HS256 都能绕过验签。

5.3 无状态带来的麻烦:怎么退出登录 ​

JWT 最大的好处是服务端不用存会话,最大的麻烦也是这个——令牌签发出去就收不回来了。清掉浏览器 Cookie 只能让这个人自己退出,令牌本身如果被别人抄走,12 小时内照样能用。

办法是自己记账。退出登录时把这次令牌的 jti 记进吊销名单,校验时先查名单:

python
def decode_token(token: str | None) -> dict | None:
    claims = jwt.decode(token, secret(), algorithms=[ALGORITHM],
                        issuer=ISSUER, options={"require": ["exp", "iat", "sub", "jti"]})
    if is_revoked(claims["jti"]):
        return None
    return claims

教学版把名单放在进程内存里,重启就清空;生产要换 Redis,否则多副本之间各记各的账。

5.4 令牌怎么变成「现在谁在说话」 ​

passenger_id 只是声明里的一个字段,用它把图配置钉到这个人身上才是关键。而这份「现在是谁、在哪条会话上」的身份,是每个请求自己带下来的一份 Desk:

python
# web/runtime/identity.py
@dataclass(frozen=True)
class Desk:
    """一次请求的身份:谁在用、在哪条会话上。"""
    passenger_id: str
    thread_id: str = ""
    username: str = ""
    role: str = "passenger"

    @property
    def config(self) -> dict:          # 交给图的就是这两样
        return {"configurable": {"passenger_id": self.passenger_id,
                                 "thread_id": self.thread_id}}

api 层在 _desk(request) 里从 JWT 解出乘客、再解析出会话(下一小节),然后把这份 Desk 一路传给 runtime 的每个函数:快照、对话、审批、订单、审计、会话目录——每个函数签名上都写着 desk。

为什么不放在一个全局字典里? 因为这个项目更早的版本就是把「当前是谁、当前在哪条会话上」放在一个进程级的 SESSION 字典里。一个人用看不出问题,两个人同时用就会串台:A 的请求把 passenger_id 改成 B 的,A 的下一句就落进 B 的档案,界面上看到的是别人的行程。这类 bug 的恶劣之处在于——单人测试永远测不出来。

一句话总结这一小节:身份是数据,不是环境。 它该跟着请求走,而不是挂在进程上;图那边本来就是无状态的(身份走 configurable),所以只要 web 层把它当参数传,多账号共用一份进程就成立了。

为什么不用 contextvars 或 thread-local 存这份身份:看着更省事,但这一层的接口是同步函数 + 线程池(FastAPI 的 def 端点、以及 SSE 那个同步生成器),一次请求的代码可能在好几个线程里跑,contextvars 和 thread-local 都可能在中途「换人」。显式传参笨一点,但每一步的身份都是看得见的。

一个 passenger_id 同时决定四件事:fetch_user_info 读谁的航班、/api/state 画谁的行程、save_preference 写进谁的 ("pref_<id>",) 档案柜、订单和会话目录按谁的名字过滤。换个账号登录,这四样一起换。

会话还有一个客户端可以带的提示:请求头 X-Thread-Id。带了就按那条来(同一账号开两个标签页,各聊各的),没带就按「最近用过的那条」解析(下一小节)。

5.5 注册为什么要过图形验证码 ​

注册对陌生人开放,又会写库,所以门口要先答一道题。切到注册页签就能看见:题板默认就在,点一下换一张。邮箱仍然要填——它是登录账号,不是验证码通道,不发邮件。

注册页:图形验证码默认就在,点题板换一张

自己做而不接 SaaS,是为了把「出题 → 回兑 → 作废」摊在桌面上。用 SVG 而不是位图:机器上没装 Pillow,而且任何 DPI 都不糊。它防的是批量脚本,不是人眼识别,所以只做三件事:字符单独摆位并抖动、叠几条干扰线、一题一用 + 5 分钟过期 + 答案只留服务端。字符集去掉 0/O、1/I/L 这类易混字,免得人看对了还被判错。

python
CHARS = "ABCDEFGHJKMNPQRSTUVWXY346789"   # 去掉 0/O、1/I/L、2/Z、5/S
LENGTH = 4                               # 4 位
TTL = 300                                # 5 分钟
_BOX: dict[str, tuple[str, float]] = {}  # 题号 -> (答案, 过期时间)

def check(captcha_id: str, answer: str) -> bool:
    with _LOCK:
        _sweep()
        item = _BOX.pop(captcha_id, None)   # 无论对错都 pop:一题只能用一次
    ...
    return answer.strip().upper() == code

题号用 secrets.token_urlsafe(不能自增,否则顺着题号就能猜);答案在进程字典里,带锁、带过期、超量清一半。一题一用:对错都 pop。这不是强防护,挡的只是最便宜的脚本。

注册的校验顺序:先判便宜的错误,再消费验证码

python
# 先把用户名 / 密码 / 邮箱 / 档案这些便宜的错误判掉,再去消费图形验证码
validate_new_user(body.username, body.password, body.passenger_id, body.email)
if not captcha.check(body.captcha_id, body.captcha_text):
    raise HTTPException(status_code=400, detail="图形验证码不正确或已过期")
user = create_user(body.username, body.password, body.passenger_id, body.email)

validate_new_user()(只判、不改)和 create_user()(判完再落库)之所以拆成两个函数,就是为了这个顺序。反过来想一下会怎样:旅客把图看对了,因为密码只有 3 位被拒——验证码已经被消费掉了,他得重新看一张图。一个低级错误要付一次看图的代价,这是设计缺陷,不是耐心问题。

validate_new_user() 内部也按便宜优先:用户名格式 → 密码长度 → 邮箱格式 → 档案是否存在 → 用户名 / 邮箱是否被占(查库最贵,放最后)。一次只报最靠前的那条。

账号表加了三列,老库怎么补 ​

users 后来多了 email、email_verified、login_fails。应用库不重建,老文件可能还没有这些列,所以 web/store.py 用 migrate() 缺哪列补哪列,不去删库:

python
USER_COLUMNS = {
    "email":          "ALTER TABLE users ADD COLUMN email TEXT",
    "email_verified": "ALTER TABLE users ADD COLUMN email_verified INTEGER NOT NULL DEFAULT 0",
    "login_fails":    "ALTER TABLE users ADD COLUMN login_fails INTEGER NOT NULL DEFAULT 0",
}

def migrate(conn):
    """把老库补齐到当前表结构;已经有的列跳过。"""
    added = []
    have = {row["name"] for row in conn.execute("PRAGMA table_info(users)")}
    for column, ddl in USER_COLUMNS.items():
        if column not in have:
            conn.execute(ddl)
            added.append(column)
    return added

每次启动跑一遍也没副作用:已有的列跳过。加字段往 USER_COLUMNS 追一条,已上线的表结构只能往前加。email 的唯一索引只覆盖有值的行;login_fails 默认 0,老账号按「没失败过」算。

5.6 登录防爆破:输错也是要成本的 ​

注册那个口子堵上了,登录这个口子还开着,而且是全天候的。它防的不是「密码被猜对」,而是「可以被无限次猜」:

python
CAPTCHA_AFTER_FAILS = 3   # 密码连错 3 次,登录页就多出一栏图形验证码
  • 同一个账号连续错 3 次之后,登录接口会在响应头里带一个 X-Captcha-Required: 1;前端看到这个头,才把图形验证码那一栏显示出来,并去 /api/auth/captcha 取一道新题。
  • 为什么用响应头,而不是在返回体里说一句「这个账号已经错了 3 次」:那又是一个账号枚举接口——没登录的人也能靠它问出「哪些账号存在」。头只告诉前端「这次得多填一格」,不回答这个账号到底存不存在。
  • 账号不存在和密码错误返回同一个 401、同一句「账号或密码不正确」。这条第 5.1 节说过,这里再补一句:提示越具体,帮到的越是攻击者。
  • 登录成功后计数清零。只涨不落的计数器很尴尬:老用户偶尔手滑几次,从此每次登录都要多填一格验证码。
  • 失败计数落在 users.login_fails 列里,不是进程内存里。重启一次就清零的话,「重启服务」就成了绕过限流最省事的办法——而让服务重启,恰恰是攻击者最擅长的事。
  • 登录框一个字段同时收用户名或邮箱(account),落库查询就是一句 WHERE username = ? OR email = ?。为什么给邮箱开后门:注册时填的就是邮箱,旅客未必记得住自己起的那个用户名。
python
def captcha_required(account: str) -> bool:
    row = find_user(account)
    if not row:
        return False     # 账号不存在就不加码:别用界面差异回答「这个账号存在吗」
    return int(row.get("login_fails") or 0) >= CAPTCHA_AFTER_FAILS

诚实的边界:这是按账号限流,不是按人限流。撒网式攻击(每个账号只试两次)它拦不住,换 IP 撞库它也拦不住。真要防撞库,得按账号 + IP + 设备三个维度一起限,再配锁定与解锁流程、异常登录告警——这些在第 13 节的表里。

5.7 中间件:默认拒绝,白名单放行 ​

保护接口最不容易写漏的方式是默认全拦,显式放行:

python
class AuthGate(BaseHTTPMiddleware):
    async def dispatch(self, request, call_next):
        path = request.url.path
        if (path in OPEN_PATHS                      # 页面、/docs
                or path.startswith("/static/")
                or path.startswith("/api/auth")):   # 登录、注册、状态查询
            return await call_next(request)
        if path.startswith("/api/") and current_session(request) is None:
            return Response('{"detail": "未登录或会话已过期"}', status_code=401)
        return await call_next(request)

写成「默认放行、黑名单拦截」就一定会漏;默认拒绝的话,漏掉某个接口的代价是它暂时用不了,而不是它裸奔。

踩坑记录:一开始放行名单里漏掉 /api/auth,结果前端连登录接口都被 401 挡住,页面永远停在「请先登录」。放行规则要覆盖登录、注册、退出和状态查询这四条路径。

又一个坑:注册接口一开始把密码长度校验放在 Pydantic(Field(min_length=6))里,短密码会返回 422 的字段级报错。注册表单想要的是「密码至少 6 位」这种能直接显示给人看的一句话,所以长度、用户名格式、档案是否存在这些业务校验统一挪到 validate_new_user() / create_user() 里(第 5.5 节讲这对函数为什么要拆开),返回 400 + 中文提示。

记一条经验:Pydantic 管「请求长得对不对」(是不是字符串、有没有少字段),业务规则管「这件事能不能做」(密码太短、用户名被占)。前者答的是机器的问题,后者要答人的问题——给人看的提示不该长成 422 / body.password / string_too_short。

5.8 角色:不是每个人都能翻全公司的账 ​

认完人只解决一半——认完之后,他能看多少? 一个平台里至少有这两种人:

角色是谁能看什么
passenger(默认)普通旅客账号自己的行程、对话、订单、偏好;订单页的审计区只看得到自己那份流水
ops运营值班账号上面全部,外加全量订单和全量审计流水(同一块审计区,内容换成全公司)

role 跟着令牌走(见上面 claims 里的 role),但真正的边界必须落在接口上:

python
@api.get("/api/audit")
def audit_log(request: Request):
    claims = require_session(request)
    if claims.get("role") != "ops":
        raise HTTPException(status_code=403, detail="审计流水只有运营账号能看")
    return {"items": audit.recent(limit=200), "summary": audit.summary()}

订单列表和统计走同一个开关(owner_view = claims.get("role") == "ops"):运营账号看全量,普通账号只看自己那几行。

这里有个原则值得单独记:把按钮藏起来不算权限。前端可以按 role 决定给不给你看全量流水,但页面上不显示的接口,抓包一样能直接调——真正的边界是服务端那句 role != "ops"。界面负责「不让人误点」,接口负责「不让人拿到」。所以订单页那块审计区对谁都在,只是内容按账号过滤:普通账号看到的是自己那份(owner_view 为假),运营账号看到的是全量。

左侧导航的「运维」页对谁都开——那一页放的是这张图自己的状态:对话状态栈、跨会话档案、节点时间线,用来讲清「图现在走到哪一层」,不是全公司账本。全量订单和全量流水仍然只出现在订单页的审计区,而且接口层那句 role != "ops" 才是真边界。

用 ops 登录后,订单页抬头会换成「运营视图 · 全部订单」:alice 的北京↔成都、bob 的上海↔三亚、carol 的广州↔西安、ops 自己的杭州↔青岛,四份档案的票都在同一张清单里。

运营账号的订单页:抬头写运营视图 · 全部订单,四份旅客的机票排在一起


6. 关键代码:闸门、栈、子图、并行 ​

6.1 动态审批:先看清包裹,再开门 ​

敏感工具不再靠编译期写死的 interrupt_before。四个专员共用一个工厂 create_sensitive_approval_gate(domain, approved_node, return_node):每个语义域闭包出自己的闸门,节点名、跳回去的专员都不一样,数据包形状是同一份。闸门把工具名、参数、tool_call_id 交给 interrupt():

python
def create_sensitive_approval_gate(domain: str, approved_node: str, return_node: str):
    def approval_gate(state: State) -> Command:
        tool_calls = state["messages"][-1].tool_calls
        decision = interrupt({
            "kind": "tool_approval",
            "domain": domain,
            "tool_calls": [
                {"id": call["id"], "name": call["name"], "args": call.get("args", {})}
                for call in tool_calls
            ],
        })
        approved = decision if isinstance(decision, bool) else bool(decision.get("approved"))
        if approved:
            return Command(goto=approved_node)
        reason = "用户拒绝了该操作"
        if isinstance(decision, dict) and decision.get("reason"):
            reason = str(decision["reason"])
        return Command(
            update={"messages": [
                ToolMessage(
                    content=f"工具调用被用户拒绝。原因:{reason}。请根据意见重新规划。",
                    tool_call_id=call["id"],
                )
                for call in tool_calls
            ]},
            goto=return_node,
        )
    return approval_gate

恢复值两种形状都认:前端点「批准执行」可能只回一个 True,也可能回 {"approved": True, "reason": "..."}。点驳回时专员收到匹配的 ToolMessage,不会留下「模型说调用了工具、历史里却没有回执」的残缺对话。租车闸门装在真子图内部,父图仍然能拿到这份包裹。

6.2 状态栈:谁在值班 ​

python
def update_dialog_stack(left: list[str], right: Optional[str]) -> list[str]:
    if right is None:
        return left
    if right == "pop":
        return left[:-1]
    return left + [right]

栈空 = 前台值班;压上 book_car_rental = 租车专员正在接待。运维页会把栈原样画出来,这就是 07 节 Handoffs 在整机里的样子(以前三栏布局时它在右侧,分页之后搬到了运维页)。

6.3 四个专员一张注册表,租车是其中唯一的真子图 ​

四个专员(机票 / 酒店 / 租车 / 门票)其实是同一张图:入口节点 → 业务助理 →(安全工具 | 审批闸门 → 敏感工具)→ 回到业务助理。差别只有名字、提示词、工具池,以及一个结构开关:平铺在父图里,还是独立 compile() 成真子图。所以装配写一遍就够了——agent/graph.py 里那张 SPECIALISTS 注册表,加一个专员只填一行。

租车是唯一把 subgraph 打开的那个:独立编译后作为父图的一个节点接入,父图只看见 book_car_rental,内部的查询、审批、写库对外封装。其余三个平铺在父图里,收工时走同一个 leave_skill 弹栈。

比价则是另一张小图:Send 按业务线动态派四个 quote_worker,quotes 用加法 reducer 汇聚,aggregate 等四路都回来再说话。只读 worker 可以挂 RetryPolicy;订车、改签这种写节点绝对不挂,避免「重试一次等于下单两次」。


7. 三本账:会话、订单、审计各放各的柜子 ​

图能跑起来以后,一个真平台还得回答三个问题:关掉浏览器再回来,对话还在不在?花了钱有没有凭证?出了事查不查得到?

这三件事对应 web/sessions.py、web/orders.py、web/audit.py 三个模块。它们共用一个前提,先把前提讲清楚,后面就顺了。

7.1 前提:两个库,一个是演示道具,一个装家底 ​

业务库每次启动都会被种子库重建(3.1 节已经划过这条线)。账号、会话标题、订单、审计流水不是道具,所以落在应用库 db/app.sqlite:users / sessions / orders / audit 四张表,缺列由 store.migrate() 补,永不重建。

db/
├── travel2.sqlite        # 业务库种子(可重建)
├── travel_new.sqlite     # 业务库运行副本(每次启动从种子重建)
├── app.sqlite            # 应用库:账号 / 会话目录 / 订单 / 审计(不重建)
├── checkpoints.sqlite    # 对话存档(LangGraph Checkpointer 自己管)
└── preferences.sqlite    # 长期记忆:跨会话偏好档案(SqliteStore,重启还在)

一句话记法:道具可以重摆,家底不能重写。

7.2 会话历史:存档归框架,目录归自己 ​

原来的 Checkpointer 是内存版 MemorySaver——进程一退,所有对话蒸发;而且每个账号固定一个 thread_id,等于只有一本对话本,聊到哪算哪。现在换成落盘的 SqliteSaver(db/checkpoints.sqlite),服务重启后历史还在。

但「历史还在」只解决一半问题。旅客要的是一堆能挑的对话:新建一条、切回上午那条、给这条改个名字、把那条删掉。这些是产品功能,LangGraph 不管。所以会话被拆成两半:

谁管存什么存在哪
Checkpointer(LangGraph)消息、状态、中断点这些图内部数据checkpoints.sqlite,表结构框架自己定
web/sessions.py(我们)属于谁、叫什么、聊了几轮、什么时候动过app.sqlite 的 sessions 表

为什么不合成一张表? 因为 Checkpointer 的表是 LangGraph 的地盘:列名和结构会随版本升级变,框架也不会给我们留「标题」「归属」这种字段。想在一个自己控制不了结构的表上做列表、改名、按账号隔离,迟早出事。反过来,sessions 表里只放产品字段,想加「置顶」「标签」随时加。

几个细节:

  • 第一次发言自动起名:新建时标题是「新对话」,第一次说话后用那句话的前 18 个字当名字,之后不再覆盖(用户改过名要保留);
  • 空草稿不留:建新对话时顺手清掉上一条「没聊过、也没改过名」的草稿,列表里最多一条「新对话 0 轮」;
  • 不能删正在用的那条:删掉自己脚下的凳子,界面会瞬间不知道该显示什么;
  • 切换会话只换 thread_id:Store 里的偏好挂在 passenger_id 上,跟会话无关——换会话对话清空、偏好还在,这正是 06 / 10 节讲的那条分界线。

「当前会话」从哪来? 身份不进全局之后(5.4 节),这件事得有个来源,规则是三档:

优先级来源场景
1请求头 X-Thread-Id同一账号两个标签页各聊各的
2这个账号「最近动过」的那条(updated_at 最大)普通刷新、重新登录后接着上次聊
3都没有就新开一条新账号第一次进来

第 2 档有个连带约定:改名不动 updated_at。那个字段的语义是「最近使用时间」,也是「当前会话」的解析依据——改个名字顺手把时间戳顶到最新,就等于悄悄把用户的当前会话换掉了。

踩坑记录(同一个坑踩了第二次):会话时间戳以前只精确到秒。同一秒里「新建 → 切换 → 删除」会全部打平,排序只能靠 rowid 兜底;而「当前会话是谁」正是靠这个排序解析的,打平就会挑错人——表现是「删掉自己正在用的那条」没被拦住、或者刚切走的对话又被弹回来。一行 strftime 加上 %f(微秒)就根治了,rowid 只留作完全相同时间戳时的兜底。

7.3 订单中心:订单是事实来源,库存标记只是它的投影 ​

先把问题摆出来:图里已经有「订酒店 / 订租车 / 订门票」这些工具,它们会把库存表里的 booked 从 0 改成 1。那订单为什么不能就靠这个标记?

因为**booked 只有 0 和 1,装不下一张订单**。它答不出:

  • 谁订的?一个平台里 booked=1 的房间可能属于任何一位旅客;
  • 花了多少钱?住三晚和住一晚,标记都是 1;
  • 什么时候订的?后来退了没有?

而且前面说过,业务库每次启动都会被种子库重建,booked 会被清回 0——订单放那儿,重启就归零。

所以订单落在应用库的 orders 表里,业务库的 booked 降级成一个投影:它只是「这间房 / 这辆车此刻有没有人订」的快捷显示,真正的账在订单表。

概念存在哪重启后角色
库存(酒店 / 租车 / 景点)业务库被种子库重建,booked 清回 0可重置的演示数据
订单应用库 orders 表原样保留事实来源

两个方向都得对上:

  • 落下单子:下单 / 改期 / 退单时,按工具名把订单写进 orders;
  • 回写标记:启动时调一次 orders.sync_inventory(),把已确认订单重新标到库存的 booked 上,看板和订单表立刻一致。

记账为什么不直接改那八个业务工具 ​

最直接的想法是:在 book_hotel、book_car_rental、cancel_* 这些工具里各加一句「写一条订单」。八个工具改八处,听着还行,麻烦在后面:

  • 改八处就是八个出错机会,接口一变八处跟着改;
  • 工具函数的职责是「跟库存打交道」,混进「管账」以后,想给订单加个字段得翻遍工具层;
  • 有些订单根本不经过工具——机票订单是从旅客名下的票推导出来的——硬塞进工具里会很别扭。

现在的做法是运行时按工具名分派:工具调用和图回执本来就要经过流式事件,在事件里认出工具名,再去 orders.py 查一张分派表:

python
TOOL_MAP = {
    "book_hotel":        (HOTEL, "hotel_id",          False, False),
    "update_hotel":      (HOTEL, "hotel_id",          False, True),
    "cancel_hotel":      (HOTEL, "hotel_id",          True,  False),
    "book_car_rental":   (CAR,   "rental_id",         False, False),
    "update_car_rental": (CAR,   "rental_id",         False, True),
    "cancel_car_rental": (CAR,   "rental_id",         True,  False),
    "book_excursion":    (SPOT,  "recommendation_id", False, False),
    "update_excursion":  (SPOT,  "recommendation_id", False, True),
    "cancel_excursion":  (SPOT,  "recommendation_id", True,  False),
}

一行一个工具,四个字段分别是「订单类型 / 参数里哪个键是库存行号 / 是不是取消 / 是不是改期」。加一门新业务,就是在分派表里加一行,八个业务工具一个字都不用动——一处改动,不是八处。

金额也不用另外存,因为它就藏在库存行里:金额 = 库存单价 × 天数(酒店按晚、租车按天、门票按张)。日期从工具参数或库存行里取,解析不出来就按 1 天算,宁可多算一块也不让金额变成 0。

机票订单走另一条路:它没有 book_* 工具,票本来就在业务库里,所以直接从旅客名下的票推导——每张票每个航段一条订单。

最后一块拼图是取消。工作台上点「取消订单」,orders.cancel_order() 把 orders 里的状态改成 cancelled,同时把业务库那一行的 booked 放回 0——两边一起动,才不会出现「订单退了、房间还占着」这种账实不符。重复点也不会报错,取消是幂等的。

7.4 审计与运维:谁能看,谁不能看 ​

订单回答「钱花在哪」,审计回答「谁干的」。登录、退出、注册、批准、驳回、下单、退单,还有新建 / 切换 / 改名 / 删除会话,全部写一条流水,每条形如「谁 · 什么时候 · 对什么 · 结果(ok / denied)」。

users 表上的 role 字段决定看得到多少(第 5.8 节讲的就是这条边界):

角色界面数据范围
passenger六个分页都在。运维页看的是这张图的状态(栈 / 档案 / 时间线),不是全公司账本;订单页的审计区只看得到自己那份自己的行程、对话、订单、偏好、自己的流水
ops同一套分页;订单页的审计区换成全量全量订单 + 全量流水(GET /api/audit 只对 ops 开)

审计本身还有一条纪律:审计写失败不能拖垮业务。所以 audit.write() 把异常整个吞掉——记流水是旁路,不能因为它写不进去就让旅客的订单失败。

7.5 顺手修掉的一处检索兜底 ​

关键词那条兜底原来用 [\u4e00-\u9fff]{2,} 这种贪婪正则切词,结果是**「怎么才能退票呢」整句被当成一个 token**——FAQ 段落里当然不会出现这七个字连在一起,于是所有段落得分全是 0。分数全平时,稳定排序只会按原始顺序取前两段,检索就退化成「永远返回开头两章」:问退票、问高反,答的都是「出行前的证件与行李清单」和「提前多久到机场」那两段,两句废话。

改成滑窗 2-gram 就好了:整句 → 怎么 / 么才 / 才能 / 能退 / 退票 / 票呢。「退票」命中《机票退改签分级规则》,「高反」命中《高原地区出行注意事项》。中文句子中间没有空格,切词这件事在兜底路径上不能偷懒。

7.6 上下文窗口:存什么和送什么,不是一回事 ​

会话存档解决的是「历史在不在」,但历史一直在长——聊到几十轮之后,两个麻烦同时出现:模型那侧的上下文越来越贵、最后顶到上限;前端那侧每来一次快照就要重新推一遍全部历史,SSE 载荷越拖越长。

所以要把两件事分开:

存什么送什么
给谁用Checkpointer(TimeTravel、审计、对账)模型 + 前端快照
留多少全量,一条不删最近一段
在哪实现SqliteSaver模型侧 agent/context.py;快照侧 SNAPSHOT_TURNS / SNAPSHOT_HISTORY

模型侧那个窗口只有十几行,但有两个坑值得单独说:

  • 必须从一条人类发言开始切。 切在别处会把「工具调用 / 工具回执」拆散,模型看到孤儿 ToolMessage 会报错或者开始胡说。所以找窗口时先在尾巴这段里找最近的 human,从那里开始给。
  • 绝不能返回空。 一开始想直接用 LangChain 的 trim_messages,实测发现:预算不够满足 start_on="human" 时它会返回空列表——模型拿到空上下文等于当场失忆(一个只剩工具消息的长尾就能触发)。所以这里自己写了一个有下界的窗口:宁可多给几条,也不把上下文清空。

前端那侧同理:快照只发最近 30 轮,截断时把 history_truncated 置真,界面照实显示一句「只显示最近的一段,完整历史留在对话存档里」——截断不丢人,装作没截断才丢人。

7.7 会员卡要能活过重启 ​

长期记忆(Store)第一版是 InMemoryStore:进程内存,重启即失忆,只够演示「跨会话还记得」这件事。现在换成 SqliteStore 落 db/preferences.sqlite,重启之后「您偏好靠窗座位」还是答得出来。换实现只动了 memory/store.py 一个文件——put / get / search 的接口完全一样,所以 tools/preferences.py 那两个工具一行都没改。再往上换 PostgresStore 也是同一个接口。

顺带修掉一个设计上的懒:以前只有模型主动调 recall_preferences 才读得到档案——模型哪一轮想不起来,偏好就白存了。现在 fetch_user_info 开局顺手读一遍,把档案拼在航班信息旁边一起写进 user_info:

python
def get_user_info(state: State, config: RunnableConfig):
    flights = fetch_user_flight_information.invoke({})
    profile = _profile_text(config)          # 从 Store 读这个人的档案
    info = f"{flights}\n\n会员档案(用户自己说过的长期偏好):\n{profile}" if profile else flights
    return {"user_info": info}

个性化从「赌模型自觉」变成了结构性事实,还省掉一次工具往返。工具照旧留着(模型想显式回忆也行),但答案不再取决于它记不记得调。

lookup_policy 检索 order_faq.md:Embedding 配得通就用向量,配不通退回关键词打分。


8. 界面:把「航显」当结构,不是皮肤 ​

前面七节讲的都是「系统怎么想」。这一节讲「它长什么样、为什么长这样」。

界面不是最后刷上去的一层漆。选一个什么样的世界来承载信息,会反过来决定信息怎么排、状态怎么读、错误往哪儿放。这台工作台选的这个世界,是航站楼里的那两块屏:一块是纸面——浅底,用来读,放说明、表单、卡片、清单;一块是航显板——机场出港牌那种,用来扫,放等宽数据列、信号灯、大字计数。

一句话说明它凭什么值得单独讲:如果界面的隐喻选对了,很多本来要靠文字解释的东西,看一眼就懂了。

8.1 一页一件事:左侧导航 + 六个分页 ​

第一版把七件事塞进一屏三栏:左边对话、中间行程、右边看板 / 订单 / 运维三个页签。结果是每一栏都在抢宽度——行程图被压成巴掌大,看板里的酒店名全是省略号,对话写长一点就把输入框顶出屏幕。

改法不是把栏调宽一点,而是换结构:左侧一条窄导航(对话 / 行程 / 探索 / 看板 / 订单 / 运维),右侧一次只放一件事。

页里面是什么什么时候看
对话会话条 + 智能体流程 + 对话 + 审批闸门要办事
行程真实底图 + 航段 + 行程概览看「我这趟怎么走」
探索16 个城市的实景卡 + 选中城市的库存卡还没想好要什么
看板全库库存的出发信息板想俯瞰整体行情
订单订单 + 本次结果卡片 + 审计流水查单、退单
运维对话状态栈 / 跨会话档案 / 节点时间线想知道「图现在走到哪一层」

导航项右边可以挂一个小格子(2 段 / 78 件 / 2 单),有新东西而人没在看那一页时点一个小圆点——和看板一样,提示存在,但不打断。下面六张是同一台工作台、同一次 alice 登录的实拍,左侧导航始终在,右侧一次只换一件事。

对话页:会话条、智能体流程、工具过程与快捷指令

行程页:真实底图叠航段弧线,右侧等宽去程 / 回程

探索页:左边 16 座城市实景卡,点成都摊开景点 / 酒店 / 租车

看板:出发信息牌,大字计数加等宽行表

订单页:alice 自己的两张机票、本次结果与本人审计流水

运维页:对话状态栈、跨会话档案、节点时间线

页面切换走地址栏的 #/board 这种锚点(六个分页的英文 key:chat / trip / explore / board / orders / ops):刷新还能停在同一页,浏览器返回键也能用。导航上写的是中文「看板」,地址栏里是 #/board——控件用通行叫法,路由用稳定的英文 key,两套不要混。

踩坑记录:路由最初写成 #board,而页面里正好有个 id="board" 的容器。浏览器把它当锚点,一进页面就自动滚到那个容器,把顶栏顶出了视口。改成 #/board(带斜杠)就没有元素跟它同名了——自己发明的锚点和 DOM 里的 id 共用一套命名空间,这是很容易踩的一脚。

8.2 设计令牌:颜色与字体各司其职 ​

所有颜色和字体收成一组 CSS 变量(在 web/present/views.py 的 :root 里)。完整令牌、状态和原则写在项目里的 DESIGN.md,产品事实写在 PRODUCT.md:

令牌值用在哪为什么是它
纸面 --paper#f1f7fd页面底冷调淡蓝白:比纯白低一档,长时间看不发飘,又不像米色那样发黄
卡面 --card#ffffff卡片、面板抬起来的那一层
板面 --panel / --panel-2#f6fafe / #e9f3fd表头、行悬停、当前导航浅蓝两档,天然把「附属区」和「正文区」分开,不用再画分隔线
藏青 --ink#10233a正文、关键数字不是纯黑:屏幕上纯黑太硬,藏青更像「墨」
主色 --accent#1a7fe8主操作、当前导航项、航线、价格明亮天蓝:机场那套招牌色的年轻版
信号绿 --lamp-ok#12b981已订、已确认、通过绿 = 落定
信号橙 --lamp-hold#ff8f2e待签、挂起夹在「还没定」和「不能动」之间那一档
信号红 --lamp-no#f4535f驳回、取消出事了
圆角 / 阴影14px / 一层薄影卡片、面板抬起来的东西要有抬起来的样子

三条取舍,都不是审美偏好:

  1. 主色只给「可以动」的东西:主按钮、当前导航项、可点的价格按钮。它一旦拿去当装饰,人就找不到「点哪儿」。
  2. 三个信号灯各有各的语义,不许混用:绿 = 落定、橙 = 待签、红 = 出事。页面上红色出现三次以上,人就再也不看它了——所以「在售航班」这种中性计数用主色蓝,不用红。
  3. 等宽只给数据:时间、编号、金额、航线、计数一律等宽体(Azeret Mono)配 tabular-nums,并且对齐成列;中文正文交给 Noto Sans SC。数据的可信感来自对齐,不是来自装饰:一堆金额和小数点对不齐,人就不信这个数;对齐了,它看起来才像账。

第一版是一套「纸票据」世界:米色纸、撕口、旋转的印章。这一版全部撤掉,换成浅底 + 圆角 + 一层薄影——用户要的是「青春活泼」,不是复古印刷。换世界的代价要说清楚:撕口、印章这些符号被删掉之后,原本由它们承担的「分组」和「状态」得各找新家,下一节就是它们的去处。

8.3 登录页:一张卡片,左说明右动手 ​

登录页是一张大卡片:

  • 左边一句产品说明,技术细节收进默认收起的提示框(口令 / 注册 / 会话 / 防爆破)。注册那一行写「要过图形验证码」,和表单开着的门一致。
  • 右边登录 / 注册两个页签;演示账号只在登录页签出现——注册是自己填邮箱、过图形验证码,不该摆一排一键进的账号。

点开左边那个提示框,四行短标签才出来:

登录说明展开:口令只存 PBKDF2 哈希、注册过图形验证码、会话走 HS256 JWT、连错三次加码

注册的图形码默认就在;登录那栏默认收起,服务端用 X-Captcha-Required 头才亮出来(5.6 节)。题板本身就是「换一张」的按钮。

8.4 航段:等宽航线 + 信号灯 ​

行程页的每个航段是一行:

  • 左端一枚站点圆点 + 一条竖线,把几段航段串成一条线——这是「行程」这个词在纸面上的最小写法。
  • 主体是一对等宽三字码(CAN → XIY)加城市名。三字码是航空业自己的短标识,等宽排起来一眼能扫到「从哪到哪」。
  • 下面一行等宽键值对:航班 / 起飞 / 到达 / 座位 / 舱位。
  • 右端一盏信号灯:绿点「去程」、绿点「回程」,这趟航段被挂起时变橙点「待签」。

状态一律用灯说,不用文字解释:圆点是「有状态」,橙色是「有人要签字」。这就是撕口和印章撤掉之后,状态语言的新家。

8.5 看板:一整块出发信息牌 ​

看板是这套界面里唯一一块「大屏」,也是需求里点名要做大的那一块:

  • 抬头一行:板名 + 年份 + 本机时间(每秒跳),时间用大号等宽数字,主色蓝。
  • 大字计数一排五个:库存总数 / 已订出 / 可订空位 / 待签挂起 / 在售航班。数字 30px 等宽,各带自己的信号色——这一排是「扫」的,不是「读」的。
  • 筛选一排按钮:全部 / 租车 / 酒店 / 门票 / 只看可订。看板不是数据库,78 条一次倒完没人看;先按类型收窄,再看细节。
  • 行表六列:信号灯 / 名称 / 城市 / 类型·档位 / 单价 / 状态。表头是等宽小字,数据列右对齐,隔行浅底——像真的航显那样排,而不是像表格那样排。
  • 排法:自己行程要去的城市排最前(广州、西安 在 carol 的榜上第一个出现),其次按城市、类型、编号。看板默认先服务「我这一趟」,再服务「随便看看」。
  • 状态真变了才翻牌:每次刷新对比上一轮的状态,只有变化的那一行闪一下(@keyframes flip,0.5 秒淡入)。动效只回答「什么变了」,不做入场表演。

8.6 订单与智能体流程 ​

  • 订单是卡片:标题 + 一盏灯(绿点「已确认」/ 灰点「已取消」)+ 城市 / 日期 / 金额三组键值对;取消过的整张降饱和、标题划线——一眼看出「这张作废了」,不用读状态字。日期区间同一天时不重复写日期(2026-09-21 06:30 → 09:10)。
  • 智能体流程收成一条跑道:档案 → 主助理 → 比价 ·〔航班 酒店 租车 门票〕· → 审批 → 交还。鼠标不用移开就能看出当前在哪一格、哪些格已经过站。四个专员收在一个虚线方框里,因为它们是「同一高度的四个柜台」,不是流水线上的四站;比价也收成一格,因为它是「一次问四路行情」这件事,而不是四条并排的线。

8.7 颜色挂在语义上,不挂在容器边上 ​

第一版卡片左侧一条粗色边,检测器一眼认出是 AI 排版指纹:气泡、审批、库存三处共用同一根线,表达的却是三件事。改法是各回各家——气泡四边细线、审批整圈琥珀框、库存交给信号灯。颜色要挂在语义单元上,不能挂在容器边缘上。


9. 界面:让回执长出图形,而不是堆文字 ​

第 8 节是视觉语言,这一节是信息长成什么形状。机场航显不会写一段「北京首都起飞、成都双流到达」——它画一条线、两端三字码。文字是让人读的,图形是让人扫的。

9.1 工具回执自己长成卡片 ​

先说一个看着有点奇怪的事实:业务工具 search_flights 返回的是 list[dict](一行一个航班),可界面上拿到的却是一坨字符串。

原因在 LangGraph:ToolMessage.content 是给模型读的文本字段,工具返回的列表被塞进去时会被转成 Python 字面量的形式,长这样:

text
[{'flight_no': 'CA4113', 'departure_airport': 'PEK', 'arrival_airport': 'CTU', ...}, {...}]

(键名与值随工具而定。)这串东西给模型读没问题,直接扔到页面上就是一屏乱码。所以渲染层要做的第一件事是把它还原回结构:

python
def _as_list(content):
    text = str(content or "").strip()
    if not text.startswith("["):
        return None
    try:
        # literal_eval 只认字面量,不会执行任意代码;这里刻意不用 eval
        rows = ast.literal_eval(text)
    except (ValueError, SyntaxError, MemoryError, RecursionError):
        return None
    if not isinstance(rows, list):
        return None
    ...

为什么是 ast.literal_eval 而不是 eval? 这是这一节最要紧的一句:

evalast.literal_eval
认什么任何 Python 表达式只有字面量:数字、字符串、列表、字典、元组、布尔、None
碰上 __import__("os").system(...)真的去执行直接报错,什么都不做
打个比方让陌生人进你家随便翻只让他拆那个写着「快递」的纸箱

关键在于这段文本的来路:它是工具返回值,而工具返回什么又取决于模型给的调用参数。也就是说,内容是「半外部输入」,不是你写死的常量。用 eval 等于把这份输入当程序跑——而一份能执行的东西,就能 rm -rf。literal_eval 的能力刚好够用(我们要的就是列表和字典),多出来的能力全是风险。

还原出结构之后,剩下的事就顺了:挑字段、画卡片。

工具卡片卡片上放什么
search_flights航班卡航班号、起降三字码、起降时间
search_hotels酒店卡名称、档位、单价 ¥620/晚、入住与离店日期
search_car_rentals租车卡车名、档位、单价 ¥xxx/天、取车与还车日期
search_trip_recommendations景点卡名称、门票价、关键词标签、攻略摘要(CSS 截两行)

一次问四类,除了四组卡片各长各的,还会多出一张比价对照卡:把四类的命中条数画成条形。为什么多这一张——四个数字横着读要来回比,四根条形竖着看,谁多谁少一眼就有,这正是「图形比文字快」最直白的版本。

几条「别把人淹没」的小规矩:

  • 同类合并:同一轮里问了两次成都的酒店,按「卡片类型 + 城市」合成一组,不重复刷屏;
  • 每组最多 12 张:再多就收口。界面不是数据库,它的任务是让人看懂,不是把行数一次倒完;
  • 只取最后一轮:只从「最后一个人类发言」往后收工具回执。上一轮查过的酒店,不该在用户问机票时还挂在屏幕上——屏幕上留的应该是这一问的答案,不是历史堆积。

踩坑记录:卡片高度不等看起来像网格错位,量过 getBoundingClientRect() 才发现三列是齐的——布局问题先量再改。另一侧:前端 esc() 如果只转义 < > &、不管引号,塞进 data-x="…" 就能提前闭合属性。补法是连两种引号一起转义,文本位置和属性位置都成立。

卡片生成放在渲染层,不改那八个业务工具:工具只管查库,展示换一版不用翻工具层;加一门业务只改映射表(和第 7.3 节订单记账同一思路);测试、终端、探索页还要读同一份回执。

9.2 一枚城市一枚图形:为什么自己画 ​

结果卡片和探索页里,每个城市带一枚几何标记(北京天坛、成都熊猫、上海东方明珠……),同城两个机场共用一枚。纯几何、扁平单色、currentColor 上色,缩到 28px 还认得出。

不引图片库,三个约束:

约束不这么做的后果
项目不引入 npm 构建链图标库大都要走打包,一个 npm install 就把「打开文件就能跑」这件事毁了
离线要能跑外链图片断网就是一片空白框
要跟着主题变色位图颜色写死,换个底色就成了补丁;currentColor 加 CSS 变量天然跟着走

每枚图形带 aria-label(例如「成都 城市标记」):图形是给眼睛的捷径,不能是唯一通道。

9.3 行程画成航图:一张底图、三层点、两条弧 ​

行程那一页的图,现在是一张真的地图:Wikimedia 的定位地图当底图(下载一次放本地,离线可用),上面叠三层符号,从后往前是:

层画什么为什么
底点层31 座省会级城市(含直辖市、自治区首府)的小灰点地图要先像地图:这些点是地理常识,写成一张静态坐标表,不依赖业务库
可订层业务库里有库存的城市:大一圈、写三字码(CTU)这层才是"能订票的地方",会随种子数据变
行程层旅客自己的航段:粗弧线两端实心点,弧顶骑航班号一眼看出"我这趟怎么走"

底点层不写标签,只在 <title> 里搁城市名(鼠标悬停报名字)。原因很实在:东部(北京 / 天津 / 石家庄 / 济南 / 郑州)挤在一起,31 个中文标签摞上去就是一坨糊;底点是"这里有座城"的坐标事实,不需要被读,只需要被看见。已经有库存的城市跳过底点,免得两个点叠在一起。

投影这一步,跟上一版比少了一步——因为底图自带投影:

python
def map_xy(lon, lat):
    x = (lon - MAP_LEFT) / (MAP_RIGHT - MAP_LEFT) * MAP_W
    y = (MAP_TOP - lat) / (MAP_TOP - MAP_BOTTOM) * MAP_H
    return round(x, 1), round(y, 1)

底图范围写死(东经 72–136、北纬 17–54),经纬度线性映射到画布,就是全部。

踩坑记录:更早那版是自己画的散点图(没有海岸线、没有省界),那时必须做余弦校正——经度一度在北纬 35 度只有赤道的 ~82% 长,不校正整张图就横向压扁。换成真实底图之后这一步反而必须删掉:底图是按等距圆柱投影画好的,你再乘一次 cos(纬度) 就是双重校正,点会整体偏西偏北。同一个公式,在不同的投影假设下,一边是必修、一边是 bug——先问清楚"这块画布的投影是什么",再决定要不要校正。

两条弧:同一对机场往返别弯向同一侧。

每段航段画一条弧而不是直线:取两端连线的中点,往垂直方向(法线)偏移一段,用二次贝塞尔曲线(SVG 的 Q 命令)画过去,弧顶骑一个航班号。

法线怎么取?最直观的写法是「把连线向量 (dx, dy) 转 90 度」。问题出在往返:去程 dx > 0(北京在左、成都在右),回程 dx < 0。同一个公式,法线方向跟着翻了个面——于是两条弧都往同一侧弯,弧顶的航班号正好叠在一起。界面上看就是「去程和回程挤成一条线,航班号糊成一团」。

修法是先归一化再取法线:

python
# 先把方向统一成「从左往右」再取法线:否则同一对机场往返时
# dx 也会跟着变号,两条弧就弯向同一侧、航班号叠在一起。
ux, uy = (-dx, -dy) if dx < 0 else (dx, dy)
lift = min(210.0, 70 + ln * 0.42) * (1 if idx % 2 == 0 else -1)
cx, cy = (ax + bx) / 2 - uy / ln * lift, (ay + by) / 2 + ux / ln * lift

两句话概括:先把方向摆正,再用序号决定往哪边弯。 第 0 段往上、第 1 段往下,第 2 段又往上——往返两条弧自然分开,航班号各在各的弧顶。lift 跟着弧长走并封顶在 210:短航段不至于弯成一张弓,长航段也不会甩出画布。alice 的北京 ↔ 成都就是这样画出来的:MU1016 往上、MU1051 往下,右侧两段等宽航线对得上(实拍见 8.1 行程页)。

这个小坑值得单记一条:几何代码里的「符号」比公式更容易错。 公式对、符号错,画出来是一张不报错的错图——程序不会告诉你哪里不对,只有你盯着屏幕觉得「怎么这么挤」。

要不要接地图 SDK / 地图 MCP?答案是:不需要。

判断标准很简单——这张图到底缺什么:

它需要的现在从哪来要不要联网
一张底图本地 SVG(Wikimedia,自由授权,署名见 CREDITS.md)不要
城市坐标静态表(省会)+ 业务库(可订城市)不要
把点连成弧十几行投影 + 贝塞尔不要

这三样没有一样需要在线服务,接一个地图 SDK 或 MCP 换来的只是:一把要管的密钥、一个会失败的网路依赖、一条"零 Key 跑测试"纪律的破口,而画出来的东西一点都不会更好——地图是我们自己画的 SVG,MCP 给的是数据和瓦片,不是设计。

那什么时候该接?当需求从「画出示意」变成「要真数据」的时候:要真实驾车时长、实时路况、地址转坐标、按公里算租车费,那就该有服务。但那时候也不一定要 MCP——我们的工具本来就是 LangGraph tool,直接调高德 / 腾讯的地图 Web 服务 API 就行(一个 key + 一次 HTTP)。MCP 的价值在别处:让智能体去调用第三方工具而不用为每个服务写胶水代码,那是第 14 章讲工具调用时的题目,不是这张静态航图的前提。

9.4 审批卡:把 JSON 翻成人话 ​

第 6.1 节讲过,审批节点把工具调用打包交给 interrupt()。LangGraph 对这份 payload 的唯一要求是「能序列化的 JSON」——至于它在界面上长什么样,是前端的事。这句话是这一小节的全部前提。

最省事的做法是把 payload 原样铺开,于是审批卡上写着:

text
book_car_rental {"rental_id": 3}

用户看到这个会怎么想?两个问题他一概答不上:这是在干什么?我该不该点批准? 一行 JSON 把判断的责任推给了旅客,可他手里没有判断的材料。

改法是字段化:把这次调用翻译成四五个字段。审批卡现在是这个形状:

字段例子从哪来
动作订车工具名 → 动作词(book_car_rental → 订车)
对象神州租车 · 北京参数里的 rental_id 回业务库查出车名与城市
日期2026-05-02 → 2026-05-05工具参数优先,缺了就从库存行取
金额¥1350库存单价 × 天数(和第 7.3 节的订单同一条算式)
合计¥1350一次挂起可能打包多个调用,合计是它们的和

三件事值得说:

  1. 数字编号要翻译成人话。 rental_id: 3 对人没有意义,查出「神州租车 · 北京」才有意义。这层翻译放在 web/orders.py 的 describe_call() 里,运行时在把审批包交给前端之前补上这些字段——渲染需要什么原料,就有人先把原料备好,前端只负责画。
  2. 金额必须和订单用同一条算式。 审批时看到的 ¥1350 和批准后落账的 ¥1350 要出自同一个算法:单价 × 天数。两处各算一遍,迟早出现「批的时候一千三,账上两千七」这种最伤信任的毛病。
  3. 取消类动作金额留空,不是 0。 退单不产生金额,写个 ¥0 反而像在收钱。

9.5 工具过程:状态用徽章说,不用解说句 ​

左侧对话里,工具调用收在一个可折叠块里。这一轮把它收拾得更干净了:

  • 抬头是 「工具过程 · 6 次调用」+ 一枚状态徽章(执行中 / 已完成);
  • 流式回答时自动展开,回答结束自动收起;
  • 正文里不再出现「正在为你查询…」这类解说句。

为什么第三条最要紧:解说句是拿正文的位置说过程的事。 用户等的是「查到几家酒店、多少钱」,不是「我正在调用工具」。而过程也不是要藏起来——它得可查:想知道模型调了什么、参数是什么,展开就有,一行不少。

徽章承担的就是那句话原来承担的活:执行中 说明在跑,已完成 说明跑完了。状态是结构,不是句子——和第 8 节「状态一律用灯说」是同一个思路,只是换到了工具过程上。

顺带说清一条「照抄官方思路」的边界:这套「归纳成一行 + 状态徽章 + 流式展开」的做法,参考的是 Vercel AI Elements 那套组件里的 Reasoning(它的官方说明里写的就是工具抬头只放名称与状态徽章、展开收起跟着流式走)。参考的是它的取舍,不是它的代码——这里没有 npm,也不需要 React,十来行字符串拼出来就是同一个意思。

9.6 分页之后:结果卡片挪去了订单页 ​

卡片不再单独占一页,而是跟订单拼在订单页:左边/上面是订单与退单,中间是「本次结果」这叠生成式卡片,下面是审计流水。理由很直接——这三样是同一件事的三个面:我问了 → 查到了什么(卡片)→ 订成了什么(订单)→ 谁批的(审计)。挤在同一个页面上下看,比来回切页省事。

早先的做法是「中栏三个页签:行程 / 结果 / 探索」。改成分页之后,行程和探索各自独立成页,结果跟订单合了——页签适合「同一件事的不同切面」,分页适合「不同的事」。行程和探索是不同的事(一个是我的行程,一个是全库库存),结果和订单是同一件事的两个切面。

结果区长出新卡片时,导航上的「订单」点一个小圆点。 圆点是「有新东西」的最小提示:对话在那一页跑,人在看别的页,一个小点告诉你「那边长出结果了」。不弹窗、不强制切换——提示存在,但不打断。

至于探索为什么和对话并存,一句话:对话适合「我要订 X」,不适合「随便看看有什么」。

把这两种心思摆在一起看:

对话探索
人的状态我已经知道要什么还没想好,先逛逛
经过模型吗经过(要理解、要分诊、要调工具)不经过,直接读业务库
代价一次模型往返,可能失败、可能想岔一次查询,快、免费、不会胡说
能下单吗能(走闸门审批)不能,只浏览

探索这一层的数据和专员工具读的是同一份库存(同一个业务库、同一批表),只是不经过模型:

text
GET /api/explore?city=成都   → 景点 / 酒店 / 租车三类卡片

所以网格上那个「6 景 3 宿 3 车」和工具查到的就是同一批东西——不会出现「探索页说还有、下单时说没有」这种两头不对。

只用对话,随便翻翻也得先组织一句话;只用浏览,复合下单没地方表达。浏览负责发现,对话负责决定。

9.7 首屏四张起手卡 ​

第一次打开时,对话区原来是一段六十多字的说明,现在换成四张可点的起手卡:

卡点了发出去的话
景点「成都有哪些必去的景点?先给我看看都有什么」
酒店「帮我看看三亚 10 月 1 日到 3 日有哪些酒店,直接列出价格」
租车「帮我在成都订一辆SUV,10月1日到10月3日」
四类比价「帮我同时看看机票、酒店、租车和景点门票的行情」

为什么是卡而不是一段说明: 读说明要「读完 → 理解 → 自己组织一句话 → 打字」,四步;卡片把这件事压成一步——点一下,话就发出去了。而且每张卡上那句话是可以直接发的完整句子,不是几个关键词。

踩坑记录:起手卡的第一版点不动。原因不难想:这些卡是动态渲染的(每次快照刷新都会重画一遍对话区的内容),而绑事件那一步在页面加载时就跑完了——那一刻按钮还不存在,后来重画出来的按钮当然没有监听器。

修法是换一种绑法:不在按钮上绑,在它们的父容器上绑一个监听器,靠事件冒泡接住被点的那个按钮。

javascript
// 起手卡是动态渲染的,用事件委托接住
document.getElementById("chat").addEventListener("click", (e) => {
  const b = e.target.closest("button[data-prompt]");
  if (b) send(b.dataset.prompt);
});

这叫事件委托:后渲染的按钮自动也算数。快捷指令和起手卡共用 data-prompt,同一个监听器两边都接得住。


10. 旅行工作台怎么跑 ​

在 code/travel_agent_v2/ 里:

bash
# 1. 建环境 + 装依赖:uv 按 uv.lock 装,解释器由 .python-version 钉在 3.13(Air 0.48 的硬约束)
uv sync

# 2. 填入真实模型 Key(与第九章同一套 .env 即可),顺便换掉令牌签名密钥
cp .env.example .env
uv run python -c "import secrets; print('TRIP_DESK_SECRET=' + secrets.token_urlsafe(48))"

# 3. 浏览器工作台(必须在本目录启动,Air 才能挂上 static/)
uv run uvicorn web.app:app --host 127.0.0.1 --port 7860

# 4. 想重建国内数据集时(会先把旧库备份成 db/travel2_intl_backup.sqlite)
uv run python -m infra.scripts.seed_cn_data

为什么依赖只有一份:以前这里是 requirements.txt,现在换成 pyproject.toml + uv.lock(和第八、九章同一套做法)。理由不是「uv 更时髦」,而是同一份依赖写两遍,迟早各写各的:两份清单分头改,谁也说不清哪份才算数,照着旧的那份装就可能缺东西。uv 原生只要一份:uv add 加依赖、uv lock 更新锁、uv sync 装上;真要给不用 uv 的读者一份传统清单,现场导出即可(uv export --format requirements-txt),导出的那份不进仓库。

打开 http://127.0.0.1:7860,先停在登录页。演示账号点一下自动填好,直接登录。改了 static/app.js 或页面 CSS 必须重启 uvicorn 才生效:Air 的 HashedStatic 会给脚本换内容哈希名,不重启页面还吃旧文件。

用户名密码角色绑定的行程
alicealice123passenger北京 ↔ 成都
bobbob123passenger上海 ↔ 三亚
carolcarol123passenger广州 ↔ 西安
opsops12345ops杭州 ↔ 青岛;运营视图可看全部订单与审计

也可以点「注册」自己建账号:填用户名、邮箱、图形验证码(点题板就换一张),再挑一份旅客档案绑定,就进去了。邮箱是登录账号,不发验证码邮件。页面顶部会显示当前模型名和当前档案号;/docs 是 FastAPI 自动文档。

需要盯终端事件流时(CLI 这条路没接 SqliteSaver,用的是内存 Checkpointer,关掉就没了;要历史落盘走浏览器工作台):

bash
uv run python main.py

终端遇到审批时输入 y 批准,其它文字会当成驳回原因送回闸门。

单 worker 纪律:jti 吊销名单和偏好档案还在进程内存里,对话存档虽然落了盘,但那把 SQLite 连接也只给一个进程用。启动命令不要加 --workers N,否则每个工人各记各的账。

.env 里 OPENAI_MODEL_NAME 必须和 OPENAI_API_BASE 配套。Embedding 配不通也不至于卡死工作台:lookup_policy 会退回 FAQ 关键词检索。没配 TRIP_DESK_SECRET 也能跑,启动日志会提醒你换掉。主助理的全网搜索(Tavily)不配就不进工具池,订票主流程不受影响。


11. 八条验收路径(浏览器里走) ​

首屏有四张起手卡,对话区下面是四张快捷指令,其它步骤直接在输入框里说。建议按这个顺序走一遍,它们对应整机里最容易“看起来懂、拼起来就乱”的八件事。

路径 A:纯查询,先看轨道 ​

点 四类行情比价。预期:

  • 智能体流程条上的「比价」先亮执行中,再落成已过站;行程图仍是北京 → 成都的去程和回程两张卡片;对话只留下汇总正文,四路查询收在思考过程里;
  • 对话里出现四类汇总,没有审批卡片;
  • 状态栈可能短暂进入子图,收工后交还前台。

这条路径验证 Routing + Send + 只读工具。失败时看节点时间线停在哪一站,而不是只盯最终回复。

路径 B:写操作,必须有人签字 ​

点 订成都 SUV。预期:

  1. 主助理把活转给租车子图;
  2. 专员先 search_car_rentals(安全工具,不拦);
  3. 准备 book_car_rental 时闸门挂起,卡片里能看见工具名和 rental_id,行程图成都站变琥珀色,看板对应车辆标「待签」;
  4. 驳回后库存看板数字不变,对话里出现拒绝回执,专员重新规划,审计里多一条 reject;
  5. 再走一遍,批准后才出现“预订成功”,看板「已订出」加一,订单页出现一条租车订单,审计里多一条 approve;
  6. 点 + 新对话:聊天清空,右侧「靠窗」档案还在;
  7. 点 回忆偏好,新会话仍能读到 Store。

这是 Checkpointer(会话)和 Store(会员卡)最直观的差别。

教学测试全绿,只说明控制流被假模型按剧本跑通了。它不证明真实订单系统已经有事务、幂等和审计。接入业务 API 之前,还要补权限、库存竞争和故障补偿。

路径 C:换个账号,看隔离是不是真的 ​

  1. 点右上角 退出登录,页面退回登录页,此时 /api/state 已经回 401——令牌被吊销了;
  2. 用 bob / bob123 登录,行程图应换成上海 ↔ 三亚,右侧档案、订单、会话列表也一起换成他的;
  3. 再退出,用 carol / carol123 登录,应该是广州 ↔ 西安,跟前两个人都不一样;
  4. 最后用 ops / ops12345 登录,行程是杭州 ↔ 青岛(运营账号有自己的档案,不和 carol 合住),订单页的审计区会变成全量订单和全量流水——你能看到 alice、bob、carol 刚才那些动作。

这条路径验证的是「谁在说话」这件事被绑到了令牌上,而不是绑在一个写死的常量上;顺便也验证了 role 那条边界:普通账号在审计区只看得到自己那份流水,就算绕过界面直接调 /api/audit,服务端回的也是 403。

路径 D:下单 → 看订单 → 取消 → 库存释放 ​

  1. 走一遍 订成都 SUV 并批准,切到 订单 页:一条租车订单,写着车名、日期区间和金额(金额 = 每日单价 × 租车天数);
  2. 记住此刻看板上那辆车是「已订」;
  3. 点订单上的 取消订单:状态变「已取消」,提示「订单已取消,库存已释放」;
  4. 切回 看板:那辆车回到空位;
  5. 再点一次取消不会报错——取消是幂等的。

这条路径把 7.3 节那句话走了一遍:订单是事实来源,库存标记只是投影。停掉服务再起来,订单还在(应用库),booked 由启动时的 sync_inventory() 重新标上(业务库),两边又对上。

路径 E:会话切换与持久化 ​

  1. 在对话列顶部点 + 新对话,问一句「拉萨会有高反吗?」——会话条上这条会自动用那句话命名,不再是「新对话」;
  2. 再开一条,问「退票扣多少钱?」,现在有两条会话;
  3. 点回第一条:对话内容跟着切回来,不是空的;
  4. 点 ✎ 改名成「高反攻略」,再点 × 删掉另一条;正在用的那条删不掉;
  5. Ctrl+C 停掉 uvicorn 再重新启动,刷新页面:会话列表和对话内容都还在。

这条路径验证的是 7.2 节那两层:图的状态归 LangGraph 的 SqliteSaver,会话的名字和归属归我们自己的 sessions 表。

路径 F:注册一个新账号(图形验证码 → 进工作台) ​

  1. 登录页切到 注册 页签:图形验证码默认就在(注册本来就要过这一道,不用等报错才出现);点一下题板就能换一张;
  2. 填用户名(小写字母 / 数字 / 下划线)和邮箱,照着题板把字符填进验证码那一栏;
  3. 故意把密码填成 3 位,挑一份档案,点「注册并登录」。预期:被拦下,提示是「密码至少 6 位」这句人话,题板没换、验证码也没被烧掉——把密码补到 6 位以上再点一次就进去了。这就是 validate_new_user() 必须先跑、而且要排在验证码校验之前的原因;
  4. 注册成功后直接进工作台(注册即登录),右上角档案号就是你挑的那份,行程图也已经是它对应的航段;
  5. 退出,用刚注册的用户名或邮箱都能登录(登录框一个字段两种都收);
  6. 再把密码连错 3 次:第 3 次之后登录页自己多出一栏图形验证码;填对这一次进去,再退出重登——那一栏不再出现(成功后计数清零)。

这条路径把「进门」这件事走全了:图形码证明对面是人,校验顺序决定他有没有被白折腾一次,失败计数决定他要不要多填一格。

路径 G:不看对话,直接翻库存(探索页) ​

  1. 点左侧导航的 探索 页:一整版城市实景卡网格,每个格子带「6 景 3 宿 3 车」这样的计数(有实景照的用照片,没抓到的回退成几何标记);
  2. 点 成都:这一格换上底色(当前城市),下面列出成都的景点 / 酒店 / 租车三类卡片,每张带价格和日期;
  3. 再点另一个城市,内容跟着换。这一路从头到尾没有经过模型——快、免费、也不会说错;
  4. 对照一下:先在探索里看到某家酒店,再回对话问一句成都的酒店,两处的数字应当一样。

这条路径验证 9.6 节那句话:浏览和对话是两条路,通往同一份库存。点开成都之后,右侧会摊开 6 景 · 3 宿 · 3 车,全程不经过模型(实拍见 8.1 探索页)。

路径 H:看生成式卡片(订单页的「本次结果」) ​

  1. 点起手卡 景点,等回答收尾;
  2. 左侧导航 订单 那一条上点出了一个小圆点——有新卡片了;
  3. 切过去:一组「景点 · 成都」的卡片,每张带名称、门票价、关键词标签和两行攻略摘要;
  4. 再发一句「帮我同时看看机票、酒店、租车和景点门票的行情」:这一次会多出一张行情对照卡,四根条形按命中条数排开,谁多谁少一眼就看出来;
  5. 注意卡片只反映最近一轮:问完机票,上一轮问的酒店卡不该还挂在那儿。

这条路径验证的是 9.1 节:工具回执在渲染层被还原成结构,查什么长什么;也验证了「只取最后一轮」——屏幕上是这一问的答案,不是历史堆积。


12. 零 Key 测试与真模型值班 ​

bash
uv run python tests/test_layering.py          # 分层守卫:六条架构规矩(静态扫源码,不连库)
uv run python tests/test_new_mechanisms.py    # 子图 / Store / Send
uv run python tests/test_web_api.py           # 31 条:验证码 / 登录防爆破 / 会话 / 订单 / 审计 / 并发隔离 / 记忆 / HTTP + 页面 + SSE
uv run python tests/smoke_real_server.py      # 线程起 uvicorn 打真实 TCP,Cookie 走真实请求头

假模型按剧本返回 tool_calls,图、工具、闸门、Web 契约全部真实运转。这是 16 节的回归锁,改拓扑之前先跑绿。

test_layering.py 不启动服务,只扫源码盯六条分层规矩(细则见项目 README)。改完先跑它。

test_web_api.py 的 31 条覆盖:

  • 身份:没登录打接口回 401、密码错回同一个 401、库里只有盐和哈希没有明文、Cookie 是三段式 JWT、篡改一个字节就失效、注册后能登录、退出登录后旧令牌作废;
  • 注册与验证码:图形码不对 / 没填就注册被拦、弱密码 / 档案不存在 / 用户名重复 / 邮箱重复都被拦且不把验证码烧掉、图形码一题一用且答案不出现在下发的 SVG 里;
  • 登录防爆破:连错 3 次要图形码、补对图形码能进、登录成功后计数清零;
  • 隔离:换账号后行程和档案跟着换、打不开别人的会话、别人的订单看不着;两个客户端交替请求各自看到自己的档案与会话(身份不进全局的回归)、带 X-Thread-Id 时按提示那条会话办事;
  • 会话:列表 / 新建 / 切换 / 改名 / 删除(正在用的删不掉)、空草稿不堆积、对话存档重启后还在且不串账号;
  • 记忆:喂模型的窗口有上限且从人类发言起切(工具配对不拆散)、长期记忆落盘换连接还在、档案自动拼进 user_info;
  • 订单与角色:批准下单后订单落账、取消订单释放库存、重复取消幂等、审计记下动作、非 ops 打 /api/audit 拿 403。

test_page_renders 只断言骨架在不在(登录、六页导航、看板、航段、审批、运维容器),不评好看不好看。大多不碰模型,一秒跑完。

测试把应用库指到临时目录(TRIP_DESK_DB_DIR,必须在 import web.app 之前设),只挪 app.sqlite / checkpoints.sqlite / preferences.sqlite,业务库仍在项目 db/。工作台默认走 .env 里的真实模型:流程一站站点亮,正文按 token 往外冒。


13. 生产边界:大厅形状对了,机场还没建完 ​

当前实现教学目的上线还要补
账号密码 + PBKDF2 + HS256 JWT看清「谁在说话」怎么绑到请求上Argon2id / bcrypt、OIDC / SSO、MFA、密钥轮转
进程内 jti 吊销名单说清 JWT 无状态带来的注销问题Redis 共享吊销表、异常登录告警
HttpOnly + SameSite=Lax Cookie先把 XSS 拿不走会话这条做到CSRF 令牌、CSP、HTTPS 强制与 Secure 属性
自己出的图形验证码(一题一用 + 5 分钟过期 + 答案留服务端)本地看清「出题 → 回兑 → 作废」整条链路行为风控 / 设备指纹 / IP 信誉、第三方风控;验证码本身不是强防护
登录失败计数落 users.login_fails连错 3 次出图形码,且重启不清零账号 + IP + 设备三维限流、锁定与解锁流程、撞库告警
注册即可绑定任意档案演示多账号隔离邀请制 / 实名核验、档案归属审批
两个本地 SQLite(应用库 / 业务库)看清「可重建的演示数据」和「用户资产」的区别订单与库存同库同事务(现在靠启动回写对齐)
手写 CSS + 设计令牌,不用前端框架看清信息怎么排、状态怎么读组件库与令牌版本化、无障碍审计、多端适配
订单按工具名运行时记账一处改动覆盖八个业务工具订单状态机、支付回调、幂等键、对账
会话目录单独一张表 + SQLite 存档看清「框架管的表」和「业务管的表」怎么分工Postgres saver、跨副本共享、软删除与保留周期
身份随请求走(每请求一份 Desk,见 5.4)看清「现在谁在说话」不该挂在进程上多副本下的会话共享(外置会话存储或粘性路由)
上下文窗口:喂模型前裁到最近 N 条(agent/context.py)区分「存什么」和「送什么」:存档保全量,上下文保够用按 token 精算预算、超长对话做摘要压缩(compaction)、按模型分别配窗口
快照只推最近 30 轮(超了给 history_truncated 标记)别让 SSE 载荷随对话无限长历史分页接口 + 前端虚拟滚动
长期记忆落 db/preferences.sqlite(SqliteStore)零外部服务也能证明「重启还记着」Postgres Store、加密、备份、保留周期
专员装配收成一张注册表 + 一个工厂(agent/graph.py)加一个专员只填一张表专员配置外置(YAML / 数据库)、按租户开关能力
单 worker + 一条 SQLite 连接执行顺序好看清队列、超时、取消、水平扩容、同一会话并发发言限流
本地 SQLite 虚拟库存本地就能改一行 booked正式业务 API、库存竞争锁、补偿事务
假模型锁控制流CI 不烧钱真模型轨迹评测、注入测试、成本与延迟

所以准确的称呼仍是:生产架构导向的教学整机。它让你看清零件如何配合,也让你看清离可售的旅行平台还差哪几道门。15 节的 langgraph.json 已经放在项目根目录,本地 Server / Studio 可以接同一张图,但观测平台(LangSmith / Langfuse)本章仍然不接入——理由见 15 节。


14. 全章收官 ​

从 01 节的飞行棋棋盘,到这一节的客服大厅屏幕,路线可以收成四段:

  • 基础轨道(01–05):State、节点、条件路由、Send、可视化与流式;
  • 控制与记忆(06、10、11、13):存档、长期档案、容错、动态中断;
  • 智能体形态(07、08、12):工具循环、Handoffs、真子图;
  • 方法论与工程化(09、14、15、16):先选型再画图、两套 API、部署取舍、整机值班。

只记一句:轨道由人铺,路口才交给模型。 大厅里那盏琥珀色的闸门灯,就是这句话的实物。


扩展阅读 ​