Skip to content
🎯 场景实战第 10 / 16 节

简单记忆系统:让 Jev 决定记什么 ​

💡 一句话总结:用 Jev 建立一套 AI 记忆系统 —— 一道 Noul 闸门决定记什么、记忆存进普通 Python 字典、调用前自动并进 state。

每个 Jev 请求只看得见你发给它的 state。客户上个月告诉你的东西——用的什么版本、什么套餐、签没签合同——不在下一次请求里,除非你的代码把它放进去。

官方 cookbook(Simple memory system)的做法直白得很:每次调用多一次 Jev 请求,加一个 Python 字典。

它的示例场景是支持工单路由。8 月,客户说他们自托管在 Windows Server 上;9 月,他们写「Dashboards have been really slow since this morning」。没有记忆,Jev 把这张工单发给云团队;有记忆,六张后续工单全部路由到期望团队——每张单独路由,其中只有三张用得上记忆。

这套 AI 记忆系统就两个部件:

  • 调用后:Jev 看着原始请求和自己的答案,回答一道 Noul——官方叫它 the gate(闸门):这份请求陈述了该记的客户事实吗?P(yes) ≥ 0.5,工单连答案一起存为该客户的记忆
  • 调用前:把该客户已存工单(带 Jev 答案)加进 state——包在 ask_with_memory() 里,参数和返回值与 ask() 相同
text
ask_with_memory(state, questions)
      │
      ▼  ① state + 该客户已存记忆(没有就原样发)
  Jev 请求 1:回答你的问题(本例:这张工单该哪个团队接)
      │  ② 原始请求 + Jev 的答案,配成一对
      ▼
  Jev 请求 2:闸门(一道 Noul)——这份请求陈述了值得记的客户事实吗?
      │
      ▼  ③ P(yes) ≥ REMEMBER_AT(0.5)?——你的代码说了算
 是 ──┤── 否 ──┐
 ▼               │
 存进 MEMORIES    │
 (每个客户 ID 一条列表)
 └──────┬────────┘
        ▼
  ④ 请求 1 的答案原样返回,你的代码照常行动
        ↺  存下的记忆进入该客户的下一次请求(回到 ①)

ask_with_memory() 这个包装函数就是一个最小的 agent harness。官方定义:harness 是模型外围给它额外能力(tool calls、循环运行)的代码。本页的 harness 只给记忆——普通 Python 字典,每个客户 ID 一条列表。想看给模型加 tool calls 的 harness,去上一课 Agent 决策。

本课约 15 分钟。页内全部数字出自官方 cookbook 的录制数据(jev-1.14.0,2026-10-29):官方录制文件去重后 31 次调用、合计 21,456 输入 token——按 $42/Btok、输出免费,约 $0.0009。录制文件随官方仓库发布,零 key 就能回放核对。

学完你能做什么 ​

  • 给任何「Jev 判断 + 客户历史」的系统加上记忆:调用后一道 Noul 闸门判断该不该记,调用前把已存记忆并进 state——ask_with_memory() 与 ask() 同参数、同返回值,换上不用改其他代码
  • 让「记什么」成为一道明文问题(GATE)而不是散落的规则:版本、套餐、合同这类持久事实以 0.96–0.98 存下;机场手机、公共假日 0.02、0.03 不存
  • 亲手复现官方实测:同一句「仪表盘很慢」,裸 Jev 路由到云团队 0.55,带上记忆后到 Windows 自托管团队 0.98——六张后续工单 3/6 变 6/6
  • 说清这套系统的五个边界(每次调用两个串行请求、记忆只增不减……),知道官方给记忆升级的下一步(相关性挑选、替换旧记忆、换数据库)

为什么用 Jev,而不是把历史全塞进 prompt? ​

能力全量塞 prompt / 让聊天模型自己记Jev 闸门记忆
记什么谁说了算❌ 全量入库:机场手机、公共假日也占上下文;聊天模型记:记什么看模型心情,输出还没法审查✅ 一道 Noul 闸门给概率:该记的 0.96–0.98、不该记的 ≤ 0.04,阈值在你代码里
概率可信吗❌ 让模型把把握写成文字,再祈祷解析不出错✅ RLCD 校准训练:概率对齐真实结果,P ≥ 0.5 这条线才有意义
历史变长之后❌ 每次请求都背着全部历史,token 随工单数线性上涨✅ 只有过了闸门的工单才进记忆,state 随「值得记的事实」增长;每次调用固定多一次闸门请求
接进现有代码❌ 改 prompt 模板、改调用链✅ ask_with_memory() 与 ask() 同参数同返回值——官方原话:换上不用改其他代码
闸门看什么❌ 连模型答案一起记:通用答案会压过请求里的真事实✅ 只数请求里客户/支持人员写下的话;答案里才出现的事实存不下来

你现在的困境 ​

  • 「客户 8 月就说清楚他们自托管在 Windows Server 上,9 月仪表盘一慢,工单还是被派给云团队——客户每次都得把环境从头再说一遍」
  • 「让聊天模型帮忙『记住客户信息』,它记回来的东西没法审:一会儿把『今天在机场用手机打的』也当成长期事实,一会儿把自己上一轮的推断又记了一遍」
  • 「试着把全部历史工单塞进每次请求:token 越烧越多,路由反而被无关的旧工单带偏」

什么时候用这一招 ​

当你需要:

  • 你的系统已经在用 Jev 做判断(路由、分类、核查),而判断质量取决于「这个客户是谁」——版本、操作系统、套餐、合同、长期偏好
  • 「记什么」是一道明文问题加一个明文阈值——人能审查、能进版本库、能随时重定
  • 每个客户一份独立记忆,新的调用自动带上,问题一个字不用改

而且不想:

  • 把全部历史塞进每次请求,token 随历史线性上涨
  • 让聊天模型自由发挥地「记」,再解析它吐出来的文本
  • 为此引入一整套嵌入检索管线——记忆长了才需要(见进阶技巧)

🎒 开始前的准备 ​

  • 完成了 Noul 是非题:闸门是一道 Noul——0–1 概率怎么读、阈值怎么设,本页直接用
  • 知道 state 是 Jev 看到的全部:记忆只是 state 里多出的一个键,问题不用跟着改
  • 跑通过安装与第一个请求:.venv 里有 typesafe-sdk,TYPESAFE_API_KEY 在环境变量里
  • 下载官方录制缓存:typesafe-public-examples 仓库 cookbooks/basic_memory 目录下的 json_cache.json,放进运行目录——照官方代码原样跑,一个 key 都不用;删掉它就全走真实调用
  • 知道成本上限:官方录制去重后 31 次调用、21,456 输入 token,按 $42/Btok 约 $0.0009、输出免费——费率见模型与价格

跟我做 ​

第 1 步:备好环境、官方缓存与五张早期工单 ​

为什么:记忆系统需要两种工单——陈述持久事实的早期工单(该记),和只说眼前事的后续消息(用得上记忆)。官方数据全部为本例手写:2 个客户、5 张早期工单、3 条后续消息。

建目录、下载官方录制缓存、装依赖:

bash
mkdir jev-memory && cd jev-memory
curl -sLO https://raw.githubusercontent.com/typesafe-ai/typesafe-public-examples/main/cookbooks/basic_memory/json_cache.json
pip install ipython 'cooksafe>=0.2.0,<0.3.0' typesafe-sdk

官方的 pip 行原本不含 typesafe-sdk(文档平台预装了),这里补上。

Setup 单元(官方原样;make_playground_link 和 display 要到最后的 Playground 单元才用到):

python
import json
import os
from pathlib import Path

from cooksafe import JsonCache, make_playground_link
from IPython.display import Markdown, display
from typesafe_sdk import Choice, Noul, NoulCriteria, SystemOneResponse, TypeSafeClient

TYPESAFE_MODEL = "jev-1.14.0"
REMEMBER_AT = 0.5  # save when P(lasting customer fact) reaches this starting threshold
MEMORY_KEY = "earlier_tickets_from_this_customer"  # where saved tickets go in the state

client = TypeSafeClient(
    api_key=os.environ.get("TYPESAFE_API_KEY", "cache-only"),  # keyless kernels replay the cache
    base_url=os.environ.get("TYPESAFE_ENDPOINT"),
    timeout=120.0,
)
json_cache = JsonCache(Path("json_cache.json"))

三个常量各管一件事:TYPESAFE_MODEL 钉死版本(官方录制数字用的 jev-1.14.0);REMEMBER_AT = 0.5 是闸门存记忆的起点阈值;MEMORY_KEY 是记忆放进 state 时用的键名。"cache-only" 是官方给 key 留的默认值——缓存命中时根本不发请求,零 key 也能复现本页全部数字。

两个客户、五张早期工单、三条后续消息(官方 DATA 原样;expected_team 由人工按客户在早期工单里说过的话标注):

python
DATA = {
    "customers": {
        "C-1042": {"id": "C-1042", "company": "Acme Corp"},
        "C-2210": {"id": "C-2210", "company": "Example Studio"},
    },
    "earlier_tickets": [
        {"id": "T-101", "customer_id": "C-1042", "received_on": "2026-08-04",
         "subject": "Adding an admin",
         "text": "Hi, we run your self-hosted edition on Windows Server 2022 in our own data "
                 "centre. How do I add a new admin user?"},
        {"id": "T-102", "customer_id": "C-2210", "received_on": "2026-08-11",
         "subject": "Exporting to PDF",
         "text": "We're a small design studio on your cloud edition, paying monthly on the "
                 "self-serve Team plan. How do I export a dashboard as a PDF?"},
        {"id": "T-103", "customer_id": "C-1042", "received_on": "2026-08-19",
         "subject": "Invoice download",
         "text": "Sorry for the typos, typing this on my phone at the airport. Where can I "
                 "download last month's invoice?"},
        {"id": "T-104", "customer_id": "C-2210", "received_on": "2026-09-02",
         "subject": "Dark mode?",
         "text": "Our whole team is out on Friday for a public holiday, so no rush on this "
                 "one. Is there a dark mode?"},
        {"id": "T-105", "customer_id": "C-1042", "received_on": "2026-09-10",
         "subject": "Sandbox environment",
         "text": "For context, we signed an Enterprise contract with you in March. Can we get "
                 "a sandbox environment for testing?"},
    ],
    "later_messages": [
        {"subject": "Slow dashboards",
         "text": "Dashboards have been really slow since this morning."},
        {"subject": "Upgrade failing",
         "text": "The upgrade to version 5.2 keeps failing halfway through."},
        {"subject": "More seats",
         "text": "We'd like to add 20 more seats before next quarter."},
    ],
    "later_received_on": "2026-09-28",
    # Labelled by hand from what each customer said in the earlier tickets.
    "expected_team": {
        "C-1042": {"Slow dashboards": "selfhosted_windows",
                   "Upgrade failing": "selfhosted_windows", "More seats": "enterprise_desk"},
        "C-2210": {"Slow dashboards": "cloud_ops", "Upgrade failing": "cloud_ops",
                   "More seats": "billing"},
    },
}
CUSTOMERS = DATA["customers"]


def ticket_state(customer_id: str, ticket_id: str, received_on: str, subject: str,
                 text: str) -> dict:
    """The state Jev sees for one ticket: a simplified ticket, like one from a helpdesk."""
    return {
        "customer": CUSTOMERS[customer_id],
        "ticket": {
            "id": ticket_id,
            "received_on": received_on,
            "subject": subject,
            "messages": [{"from": "customer", "text": text}],
        },
    }


EARLIER = [
    ticket_state(t["customer_id"], t["id"], t["received_on"], t["subject"], t["text"])
    for t in DATA["earlier_tickets"]
]
print(f"{len(CUSTOMERS)} customers, {len(EARLIER)} earlier tickets, "
      f"{len(DATA['later_messages'])} later messages. The first ticket as Jev sees it:\n")
print(json.dumps(EARLIER[0], indent=2))

你应该看到(官方发布):

text
2 customers, 5 earlier tickets, 3 later messages. The first ticket as Jev sees it:

{
  "customer": {
    "id": "C-1042",
    "company": "Acme Corp"
  },
  "ticket": {
    "id": "T-101",
    "received_on": "2026-08-04",
    "subject": "Adding an admin",
    "messages": [
      {
        "from": "customer",
        "text": "Hi, we run your self-hosted edition on Windows Server 2022 in our own data centre. How do I add a new admin user?"
      }
    ]
  }
}

每张工单的 state 就两部分(ticket_state() 拼装):customer 是谁在报,ticket 是一条简化工单线程。数据的设计意图官方写得明白:三张早期工单陈述持久事实——T-101 版本(自托管 Windows Server)、T-102 套餐(云版自助 Team)、T-105 合同(Enterprise);两张不陈述——T-103 机场手机、T-104 公共假日。三条后续消息都不提客户环境,所以没有记忆时 Jev 只能猜。

  • 2 customers, 5 earlier tickets, 3 later messages 打印无误;我能指出哪三张陈述持久事实、哪两张不陈述

第 2 步:先跑裸 Jev 路由,看没有记忆时错在哪 ​

为什么:先建立基线——没有记忆时,这张「仪表盘慢」的工单会被路由到哪。

路由题是一道 Choice,7 个选项覆盖 7 个支持团队。注意 none_suitable:官方给它注释了用途——没有团队合适时的去处,上下文不足的工单会落到这里:

python
ROUTE = {
    "team": Choice(
        instructions="Which support team should handle `ticket`?",
        criteria={
            "cloud_ops": "Cloud team: problems with the hosted cloud edition, run by us",
            "selfhosted_linux": "Self-hosted team, Linux: installs, upgrades and "
            "performance of the self-hosted edition on Linux servers",
            "selfhosted_windows": "Self-hosted team, Windows: installs, upgrades and "
            "performance of the self-hosted edition on Windows Server",
            "billing": "Billing: invoices, payments, seats and plan changes for "
            "self-serve plans",
            "enterprise_desk": "Enterprise desk: seats, contracts and account changes for "
            "customers on an Enterprise contract (their technical problems go to the "
            "technical teams)",
            "general": "General product questions: features, how-to, settings",
            "none_suitable": "None of these teams fits the ticket",
        },
    ),
}


@json_cache
def _system_one(model: str, state: str, questions: str) -> dict:
    response = client.system_one(
        state=json.loads(state), questions=json.loads(questions), model=model
    )
    return response.model_dump(mode="json")


def ask(state: dict, questions: dict) -> SystemOneResponse:
    """client.system_one, with the model pinned and the call cached."""
    as_json = {qid: q.model_dump(mode="json", exclude_none=True) for qid, q in questions.items()}
    raw = _system_one(TYPESAFE_MODEL, json.dumps(state), json.dumps(as_json))
    return SystemOneResponse.model_validate(raw)


def later_state(customer_id: str, message_index: int) -> dict:
    """One of the later messages, sent by this customer, with a fixed ticket ID."""
    n = 201 + 3 * list(CUSTOMERS).index(customer_id) + message_index
    message = DATA["later_messages"][message_index]
    return ticket_state(customer_id, f"T-{n}", DATA["later_received_on"], message["subject"],
                        message["text"])


slow = later_state("C-1042", 0)
team = ask(slow, ROUTE).answers["team"]
print(f"C-1042: {slow['ticket']['messages'][0]['text']!r}")
print(f"  plain Jev -> {team.choice} {team.probabilities[team.choice]:.2f}")

ask() 就是 client.system_one 钉上模型、带上 @json_cache 缓存;later_state() 给后续消息分配固定工单号(T-201 起)。

你应该看到(官方发布):

text
C-1042: 'Dashboards have been really slow since this morning.'
  plain Jev -> cloud_ops 0.55

这张工单单独看,「仪表盘慢」听起来就是云版的问题——Jev 发给云团队,概率 0.55。C-1042 是自托管 Windows Server 这件事,工单里一个字没提。

第 3 步:写出记忆闸门——一道 Noul 判断「值得记吗」 ​

为什么:「记什么」必须是一道明文问题,而不是散落在代码里的 if-else。先看要存的东西长什么样——as_memory() 把原始 state 和 Jev 的答案配成一对:

python
def answer_text(question, answer):
    """The readable part of an answer: the chosen option's description, or the number."""
    if answer.type == "choice":
        return question.criteria[answer.choice]
    return answer.noul if answer.type == "noul" else answer.score


def as_memory(state: dict, questions: dict, response: SystemOneResponse) -> dict:
    return {
        "request": state,
        "jev_answered": {
            q.instructions: answer_text(q, response.answers[qid])
            for qid, q in questions.items()
        },
    }


def remember_probability(memory: dict) -> float:
    """P(the request states an important fact about the customer), with the answer as context."""
    return ask(memory, GATE).answers["remember"].noul

两个键名有讲究:request 存加记忆前的原始 state——旧记忆永远不会嵌套进新记忆;jev_answered 把每个问题映射到答案文本(Choice 答案存所选项的描述)。

闸门本体是一道 Noul(官方 GATE 原文)。盯住 instructions 里那两句限制——只数 request.ticket 里客户/支持人员写下的话;jev_answered 只是上下文,它说的或暗示的都不算:

python
GATE = {
    "remember": Noul(
        instructions=(
            "In `request.ticket`, does the thread state a lasting fact about the customer that "
            "should change how their future, unrelated tickets are handled? Count what the "
            "customer or our support staff wrote in `request.ticket`. `jev_answered` shows how "
            "the ticket was routed, which may rely on things learned earlier: do not count "
            "anything it says or implies."
        ),
        criteria=NoulCriteria(
            true=(
                "Yes: in `request.ticket` the customer or our support staff state something "
                "about the customer that stays true after this ticket, such as the product "
                "edition or operating system they run, their plan or contract, or a standing "
                "preference."
            ),
            false=(
                "No: `request.ticket` only contains things specific to this one ticket or "
                "moment, such as a one-off request, today's schedule, or the device they "
                "happen to be typing on; or the lasting fact appears only in `jev_answered`."
            ),
        ),
    ),
}

为什么答案不算数?官方给了两个理由:一是通用答案——比如「General product questions: features, how-to, settings」这种谁都能对的答案——会压过请求里真正的客户事实;二是靠旧记忆得出的答案会被当成新事实再存一遍,记忆自我繁殖。

💡 中文工单先测再上线

官方说明:英语是 Jev 的主要训练语言,包括中文在内的其他语言能用但准确度较低。把 GATE 和 ROUTE 换成中文、或给中文工单做闸门前,先用自己的工单跑一批,看「该记 / 不该记」两组的概率分得开不开,再定 REMEMBER_AT。

Noul 没有单独的 confidence——想和其他题型统一门槛,可按官方公式自算 |2p − 1|(见 Confidence 置信度)。

你应该看到:这一步没有输出——remember_probability() 要到下一节才真正被调用。

第 4 步:包一层 ask_with_memory()——AI 记忆系统的核心 ​

为什么:三个小函数把闸门和 state 缝起来——这就是完整的 AI 记忆系统,官方代码一共 18 行:

python
MEMORIES: dict[str, list[dict]] = {}  # customer ID -> that customer's saved memories


def customer_id(state: dict) -> str:
    return state["customer"]["id"]


def ask_with_memory(state: dict, questions: dict) -> SystemOneResponse:
    """ask(), plus memory: same arguments, same return value."""
    # 1. Answer the call with this customer's existing memories.
    saved = MEMORIES.get(customer_id(state), [])
    response = ask({**state, MEMORY_KEY: saved} if saved else state, questions)

    # 2. Ask Jev whether the original request states an important fact worth keeping.
    memory = as_memory(state, questions, response)
    if remember_probability(memory) >= REMEMBER_AT:
        MEMORIES.setdefault(customer_id(state), []).append(memory)
    return response

读三遍这 18 行:①有记忆时发 {**state, MEMORY_KEY: saved},没记忆时原样发;②过闸门、P ≥ 0.5 才 append;③返回值与 ask() 完全相同——存没存记忆,调用方不用知道。

官方点了这套设计的五个后果,每个都值得记:

#后果细节
1每次调用两个串行请求先路由后闸门——闸门要看答案,只能在路由请求之后跑;如果你的闸门不看答案,可以并进路由请求当第二道题(并行提问与投机扇出)
2记忆只增不减每条记忆进该客户之后的每次调用;没有大小限制、没有相关性检查、没有过期
3问题从不提记忆记忆进 state 的 earlier_tickets_from_this_customer 键,Jev 读整个 state——ROUTE 一个字不用改
4state 必须有 customer.id裸 Jev 也接受字符串 state,但包装函数要靠 ID 找该客户的列表
5存下的答案会重复错误闸门不数答案,答案无法独自让工单被存;但存下的工单连着答案,之后每次调用都读它——错误推断会一直带下去

💡 这就是 Jev 的独有价值

「记什么」不是散落在 prompt 里的嘱咐,也不是全量入库的妥协——它是一道带校准概率的 Noul:官方录制里该记的三张 0.96–0.98、不该存的全 ≤ 0.04,两组隔得远远的,REMEMBER_AT = 0.5 这条线才敢画在中间。改「记什么」= 改 GATE 的措辞;改「多确定才记」= 改一个常量——都不用动调用链。

你应该看到:这一步也没有新输出——下一节让它跑起来。

第 5 步:发五张早期工单,看每条记忆怎么改写答案 ​

为什么:官方的探测实验——每张早期工单前后各发两条后续消息(慢仪表盘、加 20 席位),看每条记忆的即时影响。重复消息也过闸门,所以闸门对它们也给出判定:

python
PROBES = {"Slow dashboards": 0, "More seats": 2}  # later messages to send after each ticket


def memory_ids(cid: str) -> str:
    return ", ".join(m["request"]["ticket"]["id"] for m in MEMORIES.get(cid, [])) or "none"


def send_probes(cid: str) -> None:
    for subject, i in PROBES.items():
        team = ask_with_memory(later_state(cid, i), ROUTE).answers["team"]
        print(f"      {subject:<16} -> {team.choice:<19} {team.probabilities[team.choice]:.2f}")


for cid in CUSTOMERS:
    print(f"{cid}, memories: {memory_ids(cid)}")
    send_probes(cid)

for state in EARLIER:
    cid = customer_id(state)
    response = ask_with_memory(state, ROUTE)
    p = remember_probability(as_memory(state, ROUTE, response))  # cached: the wrapper's call
    verdict = "saved" if p >= REMEMBER_AT else "not saved"
    text = state["ticket"]["messages"][0]["text"]
    print(f"\n{cid} {state['ticket']['id']}  {response.answers['team'].choice:<19} "
          f"P(remember) {p:.2f}  {verdict}")
    print(f"    {text[:84]}")
    print(f"{cid}, memories: {memory_ids(cid)}")
    send_probes(cid)

那行 # cached: the wrapper's call 注释值得注意:包装函数里已经问过一次闸门,这里再问一次会命中缓存,不花新请求。

你应该看到(官方发布,完整探测表):

text
C-1042, memories: none
      Slow dashboards  -> cloud_ops           0.55
      More seats       -> enterprise_desk     0.53
C-2210, memories: none
      Slow dashboards  -> cloud_ops           0.68
      More seats       -> billing             0.58

C-1042 T-101  selfhosted_windows  P(remember) 0.97  saved
    Hi, we run your self-hosted edition on Windows Server 2022 in our own data centre. H
C-1042, memories: T-101
      Slow dashboards  -> selfhosted_windows  0.99
      More seats       -> enterprise_desk     0.53

C-2210 T-102  general             P(remember) 0.98  saved
    We're a small design studio on your cloud edition, paying monthly on the self-serve
C-2210, memories: T-102
      Slow dashboards  -> cloud_ops           0.98
      More seats       -> billing             0.98

C-1042 T-103  billing             P(remember) 0.02  not saved
    Sorry for the typos, typing this on my phone at the airport. Where can I download la
C-1042, memories: T-101
      Slow dashboards  -> selfhosted_windows  0.99
      More seats       -> enterprise_desk     0.53

C-2210 T-104  general             P(remember) 0.03  not saved
    Our whole team is out on Friday for a public holiday, so no rush on this one. Is the
C-2210, memories: T-102
      Slow dashboards  -> cloud_ops           0.98
      More seats       -> billing             0.98

C-1042 T-105  enterprise_desk     P(remember) 0.96  saved
    For context, we signed an Enterprise contract with you in March. Can we get a sandbo
C-1042, memories: T-101, T-105
      Slow dashboards  -> selfhosted_windows  0.98
      More seats       -> enterprise_desk     1.00

读这张表(官方解读逐条对得上):

  • 三张该存的以 0.96–0.98 存下:T-101 版本 0.97、T-102 套餐 0.98、T-105 合同 0.96
  • T-102 被路由到 general 团队,照样存下——闸门数的是客户写下的话,不是路由答案;「加 20 席位」的判定却因此从 billing 0.58 跳到 0.98
  • 机场 0.02、公共假日 0.03 不存——它们之后的探测答案一个没变
  • 每条记忆只改变与它相关的答案:C-1042 的 Windows 工单把慢仪表盘从云团队 0.55 挪到 Windows 团队 0.99,Enterprise 合同把加席位的 enterprise_desk 从 0.53 抬到 1.00;C-2210 的 Team 套餐把云团队 0.68 抬到 0.98、billing 0.58 抬到 0.98
  • 闸门对重复消息一张没存——同一张探测工单发两遍,闸门判定不变

你应该看到:你的输出与上表逐行一致(缓存回放时完全一致;live 跑概率会有差异,走向以你锁定的版本为准)。

  • 这张表跑得出来;我能说出 T-102 为什么被路由到 general 还照样存下

第 6 步:对账——同一条消息,裸 Jev 与带记忆各路由一次 ​

为什么:最终对账。每个客户发全部三条后续消息,同样的 state、同样的问题,分别走 ask() 与 ask_with_memory()。期望团队不是拍脑袋——DATA 里的注释写明:由人工按客户在早期工单里说过的话标注(C-1042 的自托管版 + Enterprise 合同,C-2210 的云版 Team 套餐):

python
EXPECTED = DATA["expected_team"]
plain_correct = memory_correct = total = 0
example_state = None


def cell(answer, expected: str) -> str:
    mark = "✓" if answer.choice == expected else "✗"
    return f"{mark} {answer.choice} {answer.probabilities[answer.choice]:.2f}"


print(f"{'customer':<9}{'message':<17}{'expected':<20}{'plain Jev':<27}{'with memory':<27}")
for cid in CUSTOMERS:
    for i, message in enumerate(DATA["later_messages"]):
        state = later_state(cid, i)
        expected = EXPECTED[cid][message["subject"]]
        plain = ask(state, ROUTE).answers["team"]
        if example_state is None:
            # Keep the exact state used for this call for the playground link.
            saved = MEMORIES.get(cid, [])
            example_state = json.loads(json.dumps({**state, MEMORY_KEY: saved} if saved else state))
        response = ask_with_memory(state, ROUTE)
        remembered = response.answers["team"]
        plain_correct += plain.choice == expected
        memory_correct += remembered.choice == expected
        total += 1
        print(f"{cid:<9}{message['subject']:<17}{expected:<20}{cell(plain, expected):<27}"
              f"{cell(remembered, expected):<27}")

print(f"\nExpected routes: {plain_correct}/{total} without memory, "
      f"{memory_correct}/{total} with memory.")

example_state 那几行是给最后 Playground 链接留的——抓下 C-1042 第一张慢仪表盘调用当时的确切 state(含当时已存的记忆)。

你应该看到(官方发布):

text
customer message          expected            plain Jev                  with memory
C-1042   Slow dashboards  selfhosted_windows  ✗ cloud_ops 0.55           ✓ selfhosted_windows 0.98
C-1042   Upgrade failing  selfhosted_windows  ✗ none_suitable 0.64       ✓ selfhosted_windows 1.00
C-1042   More seats       enterprise_desk     ✓ enterprise_desk 0.53     ✓ enterprise_desk 1.00
C-2210   Slow dashboards  cloud_ops           ✓ cloud_ops 0.68           ✓ cloud_ops 0.98
C-2210   Upgrade failing  cloud_ops           ✗ none_suitable 0.58       ✓ cloud_ops 0.95
C-2210   More seats       billing             ✓ billing 0.58             ✓ billing 0.98

Expected routes: 3/6 without memory, 6/6 with memory.

3/6 对 6/6。四行值得盯住:

  • 同一句话去不同团队:慢仪表盘,C-1042 去 Windows 团队、C-2210 留在云团队;加席位,C-1042 去 enterprise_desk、C-2210 去 billing——记忆让「怎么路由」跟着客户走,而不是只跟着这句话走
  • 两张 none_suitable 全被救回:升级失败的工单不提环境、上下文不足,裸 Jev 只能答「没团队合适」;带上记忆,两张分别去 Windows 团队 1.00、云团队 0.95
  • 裸 Jev 本来对的三行,记忆把概率抬高:C-1042 加席位从 0.53 到 1.00——记忆不只改对错,还改把握
  • 闸门对这六条后续消息一张没存——它们不陈述持久事实,记忆没有新增

📋 魔法 Prompt ​

本页的「Prompt」是给 API 的问题与包装模板——抄走、替换【】占位符(带 ? 的是选填)就能用。

📝 记忆闸门(Noul 版) ​

预期效果:把「记什么」写成一道明文是非题,拿到 0–1 概率,存不存由代码里的阈值决定。

python
GATE = {
    "remember": Noul(
        instructions=(
            "【在 `request.ticket` 里,这个线程是否陈述了关于客户的一条持久事实——"
            "它应该改变客户未来、与这张工单无关的工单的处理方式?只数客户或支持人员"
            "在 `request.ticket` 里写下的话。`jev_answered` 显示这张工单被如何路由,"
            "可能依赖早先学到的东西:它说的或暗示的任何内容都不要数。】"
        ),
        criteria=NoulCriteria(
            true="【是:客户或支持人员在 `request.ticket` 里陈述了这张工单之后仍然为真的东西——如产品版本、操作系统、套餐、合同、长期偏好】",
            false="【否:`request.ticket` 只有属于这张工单或这一刻的内容——如一次性请求、当天日程、碰巧在用的设备;或持久事实只出现在 `jev_answered` 里】",
        ),
    ),
}
# 必填项:state = as_memory() 配好的 {"request": 加记忆前的原始 state, "jev_answered": {问题 instructions: 答案文本}}
# 选填项:true/false 里的例子换成你领域「值得记 / 不值得记」的清单?
# 输出规范:response.answers["remember"].noul → 0–1 概率(Noul 无单独 confidence)
# 约束条件:
# ✅ 一道 Noul 只问「该不该记」;记多久、给谁看,是代码的事
# ✅ 官方英文原文最稳(见第 3 步);中文化先用你自己的数据验证再上线
# ❌ 不要数 `jev_answered`——通用答案会压过真事实,靠旧记忆得出的答案会被当新事实再存

✅ 记忆包装与阈值(ask_with_memory 版) ​

预期效果:调用前自动带记忆、调用后自动过闸门;与 ask() 同参数同返回值,现有代码零改动换上。

python
REMEMBER_AT = 【0.5】  # 多确定才记:官方给这道闸门的起点阈值
MEMORY_KEY = "【earlier_tickets_from_this_customer】"  # 记忆放进 state 用的键名
MEMORIES: dict[str, list[dict]] = {}  # 客户 ID -> 该客户的记忆列表


def ask_with_memory(state: dict, questions: dict) -> SystemOneResponse:
    """ask(), plus memory: same arguments, same return value."""
    saved = MEMORIES.get(customer_id(state), [])
    response = ask({**state, MEMORY_KEY: saved} if saved else state, questions)
    memory = as_memory(state, questions, response)
    if remember_probability(memory) >= REMEMBER_AT:
        MEMORIES.setdefault(customer_id(state), []).append(memory)
    return response
# 必填项:state 里必须有 customer.id——包装函数靠它找该客户的记忆列表
# 选填项:记忆长了加一道相关性问题,只挑子集并进 state?
# 输出规范:返回值与 ask() 完全相同——存没存记忆,调用方都不用管
# 约束条件:
# ✅ 先路由后闸门——闸门要看答案,只能在路由请求之后跑
# ✅ as_memory() 的 request 键存加记忆前的原始 state——旧记忆不会嵌套进新记忆
# ❌ 别把 MEMORIES 直接上线——内存字典进程退出就没了,官方 Next steps:换按客户 ID 键的数据库

🔧 你自己的路由题(Choice 版) ​

预期效果:一道 Choice 把工单派给固定团队集合;none_suitable 给上下文不足的工单一个去处。

python
ROUTE = {
    "team": Choice(
        instructions="【这张工单该哪个支持团队接?判断 `ticket`】",
        criteria={
            "【选项 ID】": "【选项描述:什么情况归这个团队】",
            "none_suitable": "【以上团队都不适合这张工单】",
        },
    ),
}
# 必填项:选项 map——ID 是代码读的,描述是 Jev 读的
# 选填项:官方 7 选项全集见第 2 步(cloud_ops / selfhosted_linux / selfhosted_windows / billing / enterprise_desk / general / none_suitable)?
# 输出规范:answers["team"].choice + probabilities(每个选项)+ confidence
# 约束条件:
# ✅ 列表可能不全时留 none_suitable(或 other)——上下文不足的工单有地方去
# ✅ 记忆进 state 后,这道题一个字不用改——Jev 读整个 state
# ❌ 不要在 instructions 里提「记忆」——记忆是 state 的事,不是问题的事

检查点 ✅ ​

  • 我能说出这套 AI 记忆系统的两个部件:调用后一道 Noul 闸门判断该不该记(P ≥ 0.5 才存),调用前把该客户已存工单连答案并进 state
  • 我知道闸门为什么只数 request.ticket 里写下的话:通用答案会压过真事实,靠旧记忆得出的答案会被当新事实再存一遍
  • 五张早期工单三存两不存:版本 / 套餐 / 合同 0.96–0.98 存下,机场手机 0.02、公共假日 0.03 不存——T-102 被路由到 general 照样存
  • 对账实验 3/6 → 6/6:同一句「仪表盘慢」,C-1042 去 Windows 团队、C-2210 留在云团队;裸 Jev 本来对的三行,记忆把概率从 0.53 抬到 1.00
  • 五个后果我能说出三个:每次调用两个串行请求、记忆只增不减、state 需要 customer.id(另两个:问题从不提记忆、存下的答案会重复错误)

踩坑提醒 ​

❌ 闸门连 Jev 的答案一起数 ​

把 jev_answered 也算进「值得记」的判断,会发生两件官方点名的事:通用答案(「General product questions: features, how-to, settings」)会压过请求里真正的客户事实——T-102 就可能存不下来;靠旧记忆得出的答案会被当成新事实再存一遍,记忆自我繁殖。

官方解法写死在 GATE 的 instructions 里:只数客户/支持人员在 request.ticket 里写下的话,jev_answered 只是上下文,它说的或暗示的都不算。

❌ 想省一次请求,随手把闸门并进路由请求 ​

闸门要看「工单 + Jev 的答案」,答案在路由请求之后才有——所以第 4 步的五个后果里,第一条就是「每次调用两个串行请求」。官方明说例外:如果你的闸门不看答案,它可以改成路由请求里的第二道题(投机扇出,见并行提问与投机扇出)。

先想清楚你的闸门要不要看答案,再决定要不要合并。

❌ state 里没有 customer.id 就换上包装函数 ​

裸 Jev 接受字符串 state,什么形状都行;ask_with_memory() 却要靠 state["customer"]["id"] 找到该客户的记忆列表。换上包装函数前,先确认你的 state 带客户 ID。

❌ 把「只增不减」的演示记忆直接上线 ​

三个「没有」官方写得直白:没有大小限制、没有相关性检查、没有过期。每条记忆进该客户之后的每一次调用——而「大 state 满是无关细节」正是 Jev 1.13 踩坑手册里的已知失败模式。

还有两个官方点名的风险:存下的工单连着答案,错误推断会一直带下去;MEMORIES 是内存字典,进程退出就没了。官方 Next steps 的解法在进阶技巧。

进阶技巧(可选) ​

调 REMEMBER_AT:官方的标定方法。 官方 Next steps 第一条就是它:从你的流量里标出一批「请求 + 答案」对,人工分「值得记 / 不值得记」两堆,挑分得最开的那个概率当阈值,再用没参与挑选的那批检查。误导记忆代价高就调高。

为什么官方起点是 0.5,比 Noul 通用起点的「yes ≥ 0.7」宽松?代码注释只说它是 starting threshold(起点阈值);Confidence 的原则是阈值随风险缩放——存一条记忆是可撤销的低风险动作,0.5 够用,代价不对等再上调。官方还给了参照:confidence-based routing 模式。

记忆长了,官方的四步升级路线。 Next steps 剩下四条,按需要上:①加一道相关性问题,从长长的记忆里挑子集并进 state;②问新记忆是否替换旧记忆,代码里删掉被替换的条目;③把 MEMORIES 换成按客户 ID 键的数据库;④把你家应用的回复也加进工单线程当支持消息,闸门连它一起判。

零 key 回放与成本账。 全部调用录在官方发布的 json_cache.json 里:去重后 31 次调用(重复的探测请求命中同一缓存条目,这是条目数少于逻辑调用数的原因)——19 次路由、12 次闸门;合计 21,456 输入 token、1,752 输出 token。按 $42/Btok 输入计费、输出免费,整页跑完约 $0.0009。

想只体验一次带记忆的路由调用:官方 cookbook 页面底部有做好的 Playground 分享链接——装着 C-1042 第一张慢仪表盘调用当时的 state(含当时已存的 T-101、T-105 两条记忆)+ 路由问题,点开就能在浏览器里改:

在 TypeSafe playground 里打开 C-1042 的工单和它的记忆 →

它由官方最后一格生成:make_playground_link(example_state, ROUTE)——第 6 步里抓下的那份确切 state。

接进你的应用。 GATE 和 ROUTE 是你自己的了,包装函数照抄。用 Claude Code 的话,装上 TypeSafe Agent Skill 再把本页交给它:

bash
claude plugin marketplace add typesafe-ai/skills
claude plugin install typesafe@typesafe-ai

官方建议把问题与阈值常量集中在一个文件里,方便人审——GATE 的措辞和 REMEMBER_AT 就是你的两处「政策」,改动都该走代码评审。

本课小结 ​

✅ 一套 AI 记忆系统 = 两个部件:调用后一道 Noul 闸门(P ≥ 0.5 才存),调用前把该客户已存工单连答案并进 state——ask_with_memory() 与 ask() 同参数、同返回值,换上不用改其他代码。

✅ 「记什么」是一道明文问题:GATE 只数请求里客户/支持人员写下的话;该记的 0.96–0.98、不该记的 ≤ 0.04——两组隔得远,0.5 这条线才敢画。

✅ 官方实测:裸 Jev 后续工单 3/6 对;带记忆 6/6 全到期望团队。慢仪表盘 0.55 云团队 → 0.98 Windows 团队;加席位 0.53 → 1.00;两张 none_suitable 全被救回。

✅ 边界清楚:每次调用两个串行请求;记忆只增不减;问题从不提记忆;state 需要 customer.id;存下的答案会重复错误。

关键配置字段回顾:

字段本页的值说明
REMEMBER_AT0.5闸门存记忆的起点阈值,官方注释原话 starting threshold
MEMORY_KEYearlier_tickets_from_this_customer记忆进 state 的键名;Jev 读整个 state,问题不用改
TYPESAFE_MODELjev-1.14.0官方录制数字的版本;live 跑锁你自己的版本 ID
MEMORIESdict[str, list[dict]],按客户 ID内存字典,进程退出就没了;上线换数据库
闸门题型Noul(无单独 confidence)阈值直接设在 P(yes) 上;要与其他题型同标尺,自算「2p − 1 的绝对值」
state 形状customer(含 id)+ ticket包装函数靠 customer.id 找记忆列表

相关教程:

下一课预告 ​

B 线(LLM 与 Agent 应用)到此走完。下一课开始 C 线(文档与数据):值提取:日期、邮箱、电话、金额。

你会学到:

  • 日期按文档问部件(绝对还是相对),代码解析校验,confidence 复核
  • 邮箱、电话、金额:正则先捞候选,Jev 挑出对的那个,代码再规范化
  • 和本页同一个习惯:提取的对错不靠模型自觉,靠类型化问题 + 代码校验
搞懂AI关于我们怎么写隐私说明

独立的第三方教程,与文中提到的任何产品及其公司没有隶属或合作关系;产品名称和商标归各自所有者。价格和政策以官方页面为准。