简单记忆系统:让 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()相同
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 条后续消息。
建目录、下载官方录制缓存、装依赖:
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 单元才用到):
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 由人工按客户在早期工单里说过的话标注):
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))你应该看到(官方发布):
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:官方给它注释了用途——没有团队合适时的去处,上下文不足的工单会落到这里:
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 起)。
你应该看到(官方发布):
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 的答案配成一对:
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 只是上下文,它说的或暗示的都不算:
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 行:
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 一个字不用改 |
| 4 | state 必须有 customer.id | 裸 Jev 也接受字符串 state,但包装函数要靠 ID 找该客户的列表 |
| 5 | 存下的答案会重复错误 | 闸门不数答案,答案无法独自让工单被存;但存下的工单连着答案,之后每次调用都读它——错误推断会一直带下去 |
💡 这就是 Jev 的独有价值
「记什么」不是散落在 prompt 里的嘱咐,也不是全量入库的妥协——它是一道带校准概率的 Noul:官方录制里该记的三张 0.96–0.98、不该存的全 ≤ 0.04,两组隔得远远的,REMEMBER_AT = 0.5 这条线才敢画在中间。改「记什么」= 改 GATE 的措辞;改「多确定才记」= 改一个常量——都不用动调用链。
你应该看到:这一步也没有新输出——下一节让它跑起来。
第 5 步:发五张早期工单,看每条记忆怎么改写答案
为什么:官方的探测实验——每张早期工单前后各发两条后续消息(慢仪表盘、加 20 席位),看每条记忆的即时影响。重复消息也过闸门,所以闸门对它们也给出判定:
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 注释值得注意:包装函数里已经问过一次闸门,这里再问一次会命中缓存,不花新请求。
你应该看到(官方发布,完整探测表):
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 套餐):
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(含当时已存的记忆)。
你应该看到(官方发布):
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 概率,存不存由代码里的阈值决定。
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() 同参数同返回值,现有代码零改动换上。
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 给上下文不足的工单一个去处。
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 再把本页交给它:
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_AT | 0.5 | 闸门存记忆的起点阈值,官方注释原话 starting threshold |
MEMORY_KEY | earlier_tickets_from_this_customer | 记忆进 state 的键名;Jev 读整个 state,问题不用改 |
TYPESAFE_MODEL | jev-1.14.0 | 官方录制数字的版本;live 跑锁你自己的版本 ID |
MEMORIES | dict[str, list[dict]],按客户 ID | 内存字典,进程退出就没了;上线换数据库 |
| 闸门题型 | Noul(无单独 confidence) | 阈值直接设在 P(yes) 上;要与其他题型同标尺,自算「2p − 1 的绝对值」 |
| state 形状 | customer(含 id)+ ticket | 包装函数靠 customer.id 找记忆列表 |
相关教程:
- Agent 决策:挑技能、转函数调用——上一课:同款「代码必须拿到上一个答案才能发下一个请求」的两次请求结构
- RAG 段落把关:只放相关内容进上下文——同族问题:把关决定「什么进上下文」,本页决定「什么进 state」,都是一道题 + 你代码里的阈值
- Confidence 置信度——调 REMEMBER_AT 之前先读:阈值随风险缩放
下一课预告
B 线(LLM 与 Agent 应用)到此走完。下一课开始 C 线(文档与数据):值提取:日期、邮箱、电话、金额。
你会学到:
- 日期按文档问部件(绝对还是相对),代码解析校验,confidence 复核
- 邮箱、电话、金额:正则先捞候选,Jev 挑出对的那个,代码再规范化
- 和本页同一个习惯:提取的对错不靠模型自觉,靠类型化问题 + 代码校验