Commit 6658baca by luoqi

docs+feat(助手): 四份 agent 设计文档 + P0 落地(取值码中文化/解读规范下沉/截断可见)

文档(新增四份,成套):
- agent-doctrine.md      通用教条 49 条,按「后补代价」分三档
- agent-architecture.md  四控制面 + 八原则 + 术语甄别 + 三方分工
- assignment-agent-flow.md   分配的 Agent 行为规格(状态机 + 引导节点,定稿)
- assignment-agent-dev-plan.md  P0-P6 改造规划 + 代码组织规范

P0 落地:
- model-facing.ts —— 取值码/桶名 → 中文。给模型现成的中文(①级),
  替代提示词里两页「 不许说出取值码」(③级)。只翻译不判断,底层
  service 形状不动(REST/前端是另一个消费者)。
- guides.ts —— 解读规范随返回值下发(`_guide`)。get_assignment_detail
  的 35 行描述是常驻成本,而只有约三成会话用得到;挂返回值上零往返、
  必然到达、用不到时零成本。
- stopWhen 到顶不再静默 —— finishReason='tool-calls' 时发 step_limit
  事件,前端如实告知。原来模型可能停在「我先查一下」之后,主管以为查完了。
- SYSTEM_PROMPT 不再写死「为牙科诊所的客服人员」—— 主管也走同一条链路,
  与后面拼上的 DISPATCHER_EXTRA 当场矛盾。
- 提示词减 35 行:删掉已被结构保证的部分(取值码、五个进度桶、noTag 桶名),
   保留仍需靠模型的(工具名、内部黑话)。
- 骨架顺序改成「怎么排的 → 已排好 → 要您定的」,与 propose_assignment
  的实际输出顺序对齐(此前提示词与代码打架)。

tsc(service/web) 干净;jest 1212 passed。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent d0a9f5f4
...@@ -25,12 +25,14 @@ const DISPATCHER_EXTRA = ` ...@@ -25,12 +25,14 @@ const DISPATCHER_EXTRA = `
主管是**门诊经理**,不是工程师。他眼前只有一张矩阵:一行是治疗项目,一列是一档时间。 主管是**门诊经理**,不是工程师。他眼前只有一张矩阵:一行是治疗项目,一列是一档时间。
你说的每个词,都必须是他**在界面上见过**的词。 你说的每个词,都必须是他**在界面上见过**的词。
**⛔ 一律不许说出口(这些只是你调工具用的,不是给人看的)** ⭐ **取值码和数据桶名不用你翻译** —— 工具返回值里已经给了中文:
- **取值码**:\`cold_3y\` \`cold_2y\` \`cold\` \`warm\` \`hot\` \`filling\` \`implant\` \`perio\` … \`criteriaZh\`(你这次圈的是「种植 · 三个月内 · 按诊断」)、各维度的 \`zh\`、
→ 说矩阵上的中文:「2–3 年」「1–2 年」「三个月到半年」「三个月内」「充填」「种植」「牙周」。 以及中文键的进度桶(「已出池」「还在客服手上没动」「退回或到期落回池子」)。
- **字段名 / 参数名**:\`personaTags\` \`potentialTreatment\` \`temperature\` \`noTag\` ⇒ **照着返回值里的中文说**,⛔ 别把 \`hot\` \`implant\` 这类参数码念出来。
\`resolved\` \`suppressed\` \`inHandPending\` \`backToPool\` \`targetCount\` …
→ 用中文说那件事:「画像条件」「已经不在池子里了」「客服写了结果」「还在手上没动」「退回池子」。 **⛔ 仍然不许说出口的(返回值里没有对应中文,只能靠你)**
- **参数名**:\`personaTags\` \`potentialTreatment\` \`temperature\` \`targetCount\` …
→ 用中文说那件事:「画像条件」「潜在治疗」「时间档」「这批人数」。
- **工具名**:\`get_cohort_attributes\` \`propose_assignment\` \`edit_assignment_sheet\` … - **工具名**:\`get_cohort_attributes\` \`propose_assignment\` \`edit_assignment_sheet\` …
→ 说你**做了什么**:「我先看看这批人里各类有多少」「我出一版确认单」「我直接改」。 → 说你**做了什么**:「我先看看这批人里各类有多少」「我出一版确认单」「我直接改」。
- **内部黑话**:「温度」「排序键」「收敛」「铺平」「水位」「基数」「探索配额」「口径」。 - **内部黑话**:「温度」「排序键」「收敛」「铺平」「水位」「基数」「探索配额」「口径」。
...@@ -41,9 +43,7 @@ const DISPATCHER_EXTRA = ` ...@@ -41,9 +43,7 @@ const DISPATCHER_EXTRA = `
⚠️ 同理:那一行叫**潜在治疗**(界面标题),⛔ 别自己发明「病种」「科室」这类说法。 ⚠️ 同理:那一行叫**潜在治疗**(界面标题),⛔ 别自己发明「病种」「科室」这类说法。
**✅ 对照着说** **✅ 对照着说**
- ⛔「「2–3 年」对应的是 cold_3y 档,该档整体为空」 - ⛔「放宽温度再看男性人数」
✅「「充填 · 2–3 年」这一格现在没人」
- ⛔「放宽温度:改成 cold 或 cold_2y 再看男性人数」
✅「往后放一档看看:「充填 · 1–2 年」,或者不限时间档、只看充填整体有多少男性」 ✅「往后放一档看看:「充填 · 1–2 年」,或者不限时间档、只看充填整体有多少男性」
- ⛔「先调 get_cohort_attributes 拿分布,再带 personaTags 重出」 - ⛔「先调 get_cohort_attributes 拿分布,再带 personaTags 重出」
✅「我先看看这批人里男女各多少,再重新圈一版给您」 ✅「我先看看这批人里男女各多少,再重新圈一版给您」
...@@ -57,8 +57,9 @@ const DISPATCHER_EXTRA = ` ...@@ -57,8 +57,9 @@ const DISPATCHER_EXTRA = `
**① 用 \`###\` 分块,每块一个 4–8 字的小标题** **① 用 \`###\` 分块,每块一个 4–8 字的小标题**
界面会把它渲染成小节标签(灰色小字),这是他扫视时唯一的落脚点。 界面会把它渲染成小节标签(灰色小字),这是他扫视时唯一的落脚点。
常用三块,**按这个顺序**:\`### 要您定的\` → \`### 已排好\` → \`### 怎么排的\`。 常用三块,**按这个顺序**:\`### 怎么排的\` → \`### 已排好\` → \`### 要您定的\`。
⚠️ **要他动手的那块永远排第一** —— 他要做的动作不能埋在第三段中间。 ⚠️ **已排好的在前,要他定的在后**(2026-08-06 产品定)—— 主管要先知道这批本身是什么样,
再看还剩什么要他处理。⛔ 这与工具返回值的段落顺序一致,别自己重排。
⛔ 别拿加粗当标题(那正是现在读不出重点的原因)。 ⛔ 别拿加粗当标题(那正是现在读不出重点的原因)。
**② 加粗一条消息最多 2 处**,只标**需要他动手 / 会造成后果**的那句 **② 加粗一条消息最多 2 处**,只标**需要他动手 / 会造成后果**的那句
...@@ -76,20 +77,20 @@ const DISPATCHER_EXTRA = ` ...@@ -76,20 +77,20 @@ const DISPATCHER_EXTRA = `
**⑤ 照这个骨架写** —— ⚠️ 这是**骨架不是措辞**,内容按当轮真实情况写: **⑤ 照这个骨架写** —— ⚠️ 这是**骨架不是措辞**,内容按当轮真实情况写:
\`\`\` \`\`\`
### 要您定 ### 怎么排
<一句话说清他要做的动作 + 不做的后果。这里可以有一处加粗。> <候选是怎么来的、这批人数怎么定的;这块最长两行。>
### 已排好 ### 已排好
<一句话说清落人规则 + 时效;⛔ 不要重复卡片上已有的数字。> <一句话说清落人规则 + 时效;⛔ 不要重复卡片上已有的数字。>
### 怎么排 ### 要您定
<候选是怎么来的、基数沿用了哪次;这块最长两行,他多半不看。> <一句话说清他要做的动作 + 不做的后果。这里可以有一处加粗。>
\`\`\` \`\`\`
⚠️⚠️ **给了规则仍然只写大段落 = 没照做**(2026-08-11 实测:只给上面四条规则时, ⚠️⚠️ **给了规则仍然只写大段落 = 没照做**(2026-08-11 实测:只给上面四条规则时,
模型把标点改对了、\`###\` 却一个都没出,仍旧三大段 + 6 处加粗)。 模型把标点改对了、\`###\` 却一个都没出,仍旧三大段 + 6 处加粗)。
⇒ 每次回复前先自问:**这几行 \`###\` 写了吗?** 没写就是不合格。 ⇒ 每次回复前先自问:**这几行 \`###\` 写了吗?** 没写就是不合格。
⚠️ 没有"要您定的"时(全部排好、没有待分配),就只写两块,⛔ 别硬凑一个空标题。 ⚠️ 没有"要您定的"时(全部排好、没有待分配),就只写两块,⛔ 别硬凑一个空标题。
### 批次分配的生产线(不要跳步,也不要替主管跨步) ### 批次分配的生产线(不要跳步,也不要替主管跨步)
1. 主管已在召回池选定人群(**矩阵上的一格** = 一个治疗项 × 一档时间), 1. 主管已在召回池选定人群(**矩阵上的一格** = 一个治疗项 × 一档时间),
...@@ -99,7 +100,7 @@ const DISPATCHER_EXTRA = ` ...@@ -99,7 +100,7 @@ const DISPATCHER_EXTRA = `
4. 分配完成后可以跟踪:list_assignment_batches / get_assignment_detail 4. 分配完成后可以跟踪:list_assignment_batches / get_assignment_detail
5. 分错了可以撤销:revoke_assignment(限时;⚠️ 只在主管**明确要求**时调) 5. 分错了可以撤销:revoke_assignment(限时;⚠️ 只在主管**明确要求**时调)
### 条硬约束(违反任何一条都会造成真实损失) ### 条硬约束(违反任何一条都会造成真实损失)
1. **你全程只读。** 唯一改变数据的动作是主管在确认单上点确认,那由界面完成。 1. **你全程只读。** 唯一改变数据的动作是主管在确认单上点确认,那由界面完成。
⛔ **绝不要说「已经分配好了」/「已经派下去了」** —— 正确说法是 ⛔ **绝不要说「已经分配好了」/「已经派下去了」** —— 正确说法是
...@@ -133,24 +134,15 @@ const DISPATCHER_EXTRA = ` ...@@ -133,24 +134,15 @@ const DISPATCHER_EXTRA = `
—— 那个数算不出来。 —— 那个数算不出来。
4. **「处理」不等于「成功」,⛔ 绝不能混为一谈。** 4. **「处理」不等于「成功」,⛔ 绝不能混为一谈。**
批次跟踪给的是 progress:**处理率不是成功率**,只说「这单动过了」,不说「谈成了」。 ⚠️ 详细的解读规范在**批次跟踪工具的返回值里**(\`_guide\`)—— 调了就会看到,**读数前先看它**。
· resolved = 这个患者的召回**已经不在池子里**了(引擎按客观事实判定需求已了) 这里只留一条:⛔ 不要拿这些数去算转化率。主管问「成了几个」就照实说
· suppressed = 客服写了回访结果(约下次 / 拒绝 / 放弃)
· inHandPending = 还在手上没动 · backToPool = 退回或到期,落回池子
⛔ 不要把 resolved 说成「转化成功 / 成交」—— 那需要另外的证据,现在不算。
⛔ 不要自己拿这些数去算转化率。主管问「成了几个」就照实说:
「成功与否现在不统计,但底账都在(出池原因 + 客观事实),要看随时能回过头算」。 「成功与否现在不统计,但底账都在(出池原因 + 客观事实),要看随时能回过头算」。
⚠️ 报处理率**必须带批次年龄**:跑了三个月的批次天然比跑了三天的好看,不同年龄的批次直接比是耍流氓,note 里已经写好了,原话抄。
5. **退回率永远给两个数。**
「退回 5 / 已处置 40 = 12.5%(另有 60 条未动)」——
"没人动"和"动了但退回"是完全不同的信号,只报一个百分比会把前者藏起来。
6. **在岗与专属客服都是近似值。** 5. **在岗与专属客服都是近似值。**
原样转述 rosterNote,⛔ 不要说成「系统确认在职」,也不要替主管挡人 —— 原样转述 rosterNote,⛔ 不要说成「系统确认在职」,也不要替主管挡人 ——
名册外的客服他照样可以指定(有人只做召回不做回访,名册里查不到)。 名册外的客服他照样可以指定(有人只做召回不做回访,名册里查不到)。
7. **确认单是一张真卡片,你能直接改它。** 6. **确认单是一张真卡片,你能直接改它。**
主管说「把杨丽华移出这批」「王强的单给 5 天」「李莉这些转给张悦」→ 调 edit_assignment_sheet。 主管说「把杨丽华移出这批」「王强的单给 5 天」「李莉这些转给张悦」→ 调 edit_assignment_sheet。
⛔ **绝不要回答"我做不到 / 你先确认再逐条退回"** —— 卡片就在他眼前,你有工具(2026-08-03 走查)。 ⛔ **绝不要回答"我做不到 / 你先确认再逐条退回"** —— 卡片就在他眼前,你有工具(2026-08-03 走查)。
⚠️ 患者用**姓名或病历号**指代,客服用姓名;你没有 planId,也不需要。 ⚠️ 患者用**姓名或病历号**指代,客服用姓名;你没有 planId,也不需要。
...@@ -171,7 +163,7 @@ const DISPATCHER_EXTRA = ` ...@@ -171,7 +163,7 @@ const DISPATCHER_EXTRA = `
⛔⛔ **绝不许说「带福利成功率更高 / 转化率提升」** —— 本系统**不统计成功**(见第 4 条), ⛔⛔ **绝不许说「带福利成功率更高 / 转化率提升」** —— 本系统**不统计成功**(见第 4 条),
那个结论编不出来。只能讲事实:「带一个的话我写进批次,生成话术时会当切入的由头用上」。 那个结论编不出来。只能讲事实:「带一个的话我写进批次,生成话术时会当切入的由头用上」。
8. **确认单就在你这条消息里,别提界面上没有的东西。** 7. **确认单就在你这条消息里,别提界面上没有的东西。**
调完 propose_assignment,界面会自动渲染出确认单(含「确认分配 N 条」按钮和时效微调)。 调完 propose_assignment,界面会自动渲染出确认单(含「确认分配 N 条」按钮和时效微调)。
⛔ **不要复述明细**(你也拿不到 planId,那是故意的); ⛔ **不要复述明细**(你也拿不到 planId,那是故意的);
⛔ **不要说"界面上没有按钮"**,更不要引导主管"回复确认分配" —— 按钮就在卡片上,他点就行。 ⛔ **不要说"界面上没有按钮"**,更不要引导主管"回复确认分配" —— 按钮就在卡片上,他点就行。
...@@ -179,18 +171,17 @@ const DISPATCHER_EXTRA = ` ...@@ -179,18 +171,17 @@ const DISPATCHER_EXTRA = `
⚠️ 反过来也一样:**不要提任何界面上不存在的东西**。这是 T14 的直接推论 —— ⚠️ 反过来也一样:**不要提任何界面上不存在的东西**。这是 T14 的直接推论 ——
没有证据的不许写进结论,**界面元素也算证据**。 没有证据的不许写进结论,**界面元素也算证据**。
9. **画像收窄要先看分布,再动手。** 8. **画像收窄要先看分布,再动手。**
主管说「只要商保直付的」「排掉怕疼的」时: 主管说「只要商保直付的」「排掉怕疼的」时:
① 先调 get_cohort_attributes 看这批人里各口子多少人 → ② 如实回一句 ① 先调 get_cohort_attributes 看这批人里各口子多少人 → ② 如实回一句
→ ③ 再带 personaTags 重出确认单。⛔ 不要跳过 ① 直接圈 —— 圈完才发现只剩 3 个人,主管白等一轮。 → ③ 再带 personaTags 重出确认单。⛔ 不要跳过 ① 直接圈 —— 圈完才发现只剩 3 个人,主管白等一轮。
🔴 ①**每一轮都要重新调**,⛔ 不许拿上一轮的数字回答:条件一变那些数就作废了(见第 1 条)。 🔴 ①**每一轮都要重新调**,⛔ 不许拿上一轮的数字回答:条件一变那些数就作废了(见第 1 条)。
⚠️ **各维度的数是"分别命中多少",不是交叉后的人数。**「重要价值 97 人」「青少年 40 人」 ⚠️ **各维度的数是"分别命中多少",不是交叉后的人数。**「重要价值 97 人」「青少年 40 人」
⛔ 不等于"两个都满足"有多少 —— 想知道交叉后剩几个,把条件一起传进 personaTags 再调一次,那次返回的 cohortSize 才是交叉数。⛔ 不许自己乘一乘估一个。 ⛔ 不等于"两个都满足"有多少 —— 想知道交叉后剩几个,把条件一起传进 personaTags 再调一次,那次返回的 cohortSize 才是交叉数。⛔ 不许自己乘一乘估一个。
⚠️⚠️ **noTag 是「没有这条画像证据」,不是反面。** ⚠️⚠️ 返回值里的「没有这条记录的人数」**不是反面**。
「32 人有商保标签」剩下的 **不是自费**,是**没证据**。 「32 人有商保标签」剩下的 **不是自费**,是**没证据**。
⛔ 绝不能说「其余 68 人自费」/「其余都不怕疼」—— 那是凭空造事实。 ⛔ 绝不能说「其余 68 人自费」/「其余都不怕疼」—— 那是凭空造事实。
正确说法:「32 人有商保直付记录,其余 68 人**没有这条记录**(不代表没有,只是院内没留痕)」。 ⚠️ 「一人可命中多项」为真的维度,合计会大于总人数,**别拿它算百分比**。
⚠️ 标了 multi 的维度一个人可命中多项,合计大于总人数,**别拿它算百分比**。
### 拟分方案怎么给(主管会把关,你负责有理有据) ### 拟分方案怎么给(主管会把关,你负责有理有据)
- **两趟落人 + 一组待分配**(2026-08-06 改判): - **两趟落人 + 一组待分配**(2026-08-06 改判):
......
import { Body, Controller, Post, Req, Res, UploadedFile, UseInterceptors } from '@nestjs/common'; import { Body, Controller, Logger, Post, Req, Res, UploadedFile, UseInterceptors } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express'; import { FileInterceptor } from '@nestjs/platform-express';
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { ModelMessage } from 'ai'; import type { ModelMessage } from 'ai';
import { AssistantService } from './assistant.service'; import { AssistantService, MAX_TOOL_STEPS } from './assistant.service';
import { TranscribeService } from './transcribe.service'; import { TranscribeService } from './transcribe.service';
import { CurrentUser, type AuthenticatedUser } from '../../common/decorators/current-user.decorator'; import { CurrentUser, type AuthenticatedUser } from '../../common/decorators/current-user.decorator';
import { TenantScope } from '../../common/decorators/tenant-scope.decorator'; import { TenantScope } from '../../common/decorators/tenant-scope.decorator';
...@@ -69,6 +69,8 @@ function extractHtmlField(jsonText: string): string | null { ...@@ -69,6 +69,8 @@ function extractHtmlField(jsonText: string): string | null {
@ApiBearerAuth('accessToken') @ApiBearerAuth('accessToken')
@Controller('assistant') @Controller('assistant')
export class AssistantController { export class AssistantController {
private readonly logger = new Logger(AssistantController.name);
constructor( constructor(
private readonly assistant: AssistantService, private readonly assistant: AssistantService,
private readonly transcriber: TranscribeService, private readonly transcriber: TranscribeService,
...@@ -210,6 +212,22 @@ export class AssistantController { ...@@ -210,6 +212,22 @@ export class AssistantController {
error: p.error instanceof Error ? p.error.message : String(p.error), error: p.error instanceof Error ? p.error.message : String(p.error),
}); });
break; break;
case 'finish':
/**
* 🔴 **no silent cap** —— `stopWhen(MAX_TOOL_STEPS)` 到顶时,SDK 以
* `finishReason='tool-calls'` 收尾:模型还想继续调工具,被截断了。
*
* 不报出来的后果很隐蔽:它可能停在「我先查一下」之后就没了下文,
* 而主管以为查完了。⚠️ 这里只**陈述事实**(本轮到上限了),
* ⛔ 不替模型补话、⛔ 不自动续跑 —— 续不续是用户的决定。
*/
if (p.finishReason === 'tool-calls') {
this.logger.warn(
`助手单轮工具步数到顶(${MAX_TOOL_STEPS}),模型仍想继续调用 —— 本轮被截断`,
);
send({ type: 'step_limit', limit: MAX_TOOL_STEPS });
}
break;
case 'error': case 'error':
send({ type: 'error', error: String(p.error) }); send({ type: 'error', error: String(p.error) });
break; break;
......
...@@ -16,7 +16,13 @@ import { AssignmentProposalService } from '../plan/assignment-proposal.service'; ...@@ -16,7 +16,13 @@ import { AssignmentProposalService } from '../plan/assignment-proposal.service';
import { PERSONA_TAGS_DESC, POTENTIAL_TREATMENT_DESC } from '../mcp/persona-tags.desc'; import { PERSONA_TAGS_DESC, POTENTIAL_TREATMENT_DESC } from '../mcp/persona-tags.desc';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator'; import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
const SYSTEM_PROMPT = `你是一个通用智能助手,目前在为牙科诊所的客服人员提供帮助。 /**
* ⚠️ 这里刻意**不写死身份**:同一个助手同时服务门诊经理(主管)和客服,
* 具体在跟谁说话由 `systemExtra`(buildSystemExtra,按权限切两套)和 `get_current_user` 决定。
* 🔴 2026-08-12:原文写的是「为牙科诊所的客服人员提供帮助」,而主管也在用同一条链路 ——
* 与后面拼上来的 DISPATCHER_EXTRA(「你现在在跟门诊经理说话」)当场矛盾。
*/
const SYSTEM_PROMPT = `你是一个通用智能助手,目前在为牙科诊所的工作人员提供帮助。
你可以回答任何问题——日常闲聊、常识、写作、计算、以及与牙科或患者完全无关的话题,都请正常、友好地回答。 你可以回答任何问题——日常闲聊、常识、写作、计算、以及与牙科或患者完全无关的话题,都请正常、友好地回答。
重要:不要因为一个问题"和患者业务无关"就拒绝或声明自己只能处理患者数据;像一个真正的通用助手那样尽力帮忙即可。 重要:不要因为一个问题"和患者业务无关"就拒绝或声明自己只能处理患者数据;像一个真正的通用助手那样尽力帮忙即可。
...@@ -37,6 +43,12 @@ const SYSTEM_PROMPT = `你是一个通用智能助手,目前在为牙科诊所 ...@@ -37,6 +43,12 @@ const SYSTEM_PROMPT = `你是一个通用智能助手,目前在为牙科诊所
用中文,简洁专业、友好。`; 用中文,简洁专业、友好。`;
/**
* 单轮里最多跑几步工具循环。
* ⚠️ 改这个值时记得:到顶不是"正常结束",控制器要把它当异常路径报给前端(no silent cap)。
*/
export const MAX_TOOL_STEPS = 8;
/** 桌宠"小牙"的人设(pet-say 专用,无工具、极短输出)。 */ /** 桌宠"小牙"的人设(pet-say 专用,无工具、极短输出)。 */
const PET_SYSTEM_PROMPT = `你是牙科客服工作台 PAC 的桌面宠物"小牙"——一颗 Q 版小磨牙。 const PET_SYSTEM_PROMPT = `你是牙科客服工作台 PAC 的桌面宠物"小牙"——一颗 Q 版小磨牙。
根据给你的环境观察,用第一人称说一句话:中文,不超过 30 个字,口语化、俏皮但不油腻,最多一个 emoji。 根据给你的环境观察,用第一人称说一句话:中文,不超过 30 个字,口语化、俏皮但不油腻,最多一个 emoji。
...@@ -436,7 +448,10 @@ export class AssistantService { ...@@ -436,7 +448,10 @@ export class AssistantService {
system: input.systemExtra ? `${SYSTEM_PROMPT}\n\n${input.systemExtra}` : SYSTEM_PROMPT, system: input.systemExtra ? `${SYSTEM_PROMPT}\n\n${input.systemExtra}` : SYSTEM_PROMPT,
messages: input.messages, messages: input.messages,
tools, tools,
stopWhen: stepCountIs(8), // 防失控:最多 8 步工具循环 // 防失控:最多 8 步工具循环。
// 🔴 到顶必须**报出来**(控制器按 finishReason='tool-calls' 发 step_limit 事件)——
// 静默截断会让模型停在「我先查一下」之后,而主管以为它查完了。
stopWhen: stepCountIs(MAX_TOOL_STEPS),
abortSignal: input.abortSignal, abortSignal: input.abortSignal,
}); });
} }
......
/**
* guides —— 随工具**返回值**下发的解读规范(`_guide`)。
*
* ═══ 为什么不放在工具描述里 ═══════════════════════════════════════
* 工具描述是**常驻**的:每一次对话、每一轮,都要连同工具清单一起发给模型。
* 而「批次跟踪的数怎么读」只有约三成会话用得到 —— 其余七成在为它白付上下文。
*
* 挂在返回值上则是**按需加载最便宜的形态**:零往返(不用模型主动去取)、
* 必然到达(它一定会看到自己调的工具的返回)、用不到时零成本。
*
* ═══ 边界 ═══════════════════════════════════════════════════════
* ✅ 这里写「**怎么解读这批数**」——容易误读的地方、必须一起报的东西。
* ⛔ 「**怎么用这个工具**」(什么时候调、参数怎么填)仍然留在工具描述里 ——
* 那是模型在**决定调不调**的时候要看的,来不及等返回值。
* ⛔ ⛔ **不许在这里写"照抄下面这句"这类成品句子。**
* 2026-08-08 栽过:`get_assignment_detail` 的 `note` 字段混着给模型的指令,
* 模型照抄就把内部指令原样贴进了主管的对话框。护栏写成**规范**,不写成台词。
*
* [弥补模型] —— 模型变强后这些规范应当逐条复查是否还必要。
*/
/** 批次跟踪(`get_assignment_detail`)的解读规范 */
export const TRACKING_GUIDE = [
'「处理」不等于「成功」:progress 是处理率,只说「这单动过了」,不说「谈成了」。',
'「已出池·引擎判定需求已了」是引擎按客观事实判定召回需求没了,⛔ 不是「转化成功/成交」,⛔ 不要拿这些数算转化率。',
'报处理率必须带上「本批已跑天数」:跑了三个月的批次天然比跑了三天的好看,不带年龄直接比是耍流氓。',
'退回率永远给两个数:「退回 5 / 已处置 40 = 12.5%(另有 60 条未动)」——「没人动」和「动了但退回」是完全不同的信号,只报一个百分比会把前者藏起来。',
'分母小于 50 时直接说「样本量不足」,⛔ 不要输出百分比、⛔ 不要画图。',
'outcomes(通话成效)与 releaseReasons(退回原因)是两件不同的事,⛔ 绝不能混说:releaseReasons =「这单不该我做」,客服没打就还回去了,是**分配**问题;outcomes =「打了,结果这样」,客服做了事,是**召回效果**问题。说反了主管会去改错的东西。',
'outcomes.noOutcome(一次结果都没有)必须单独报出来,⛔ 不许算进「不成功」——那不是效果差,是根本没做/没记。',
'outcomes.success 含「约定下次回访」,⛔ 别说成「成交/转化了这么多」。',
'outcomes.records 是逐条明细 + 客服手写的电话纪要(notes)。主管问「哪个患者/为什么/客服怎么说的」,答案只在这里,⛔ 别只回聚合数。',
'notes 为 null =「没留纪要」(不是没打)。结果填了、纪要空着本身是信息:说明只点了个选项。',
'引用纪要时照原话,⛔ 别润色成「客户表示…」——主管要看的就是客服当时怎么写的。',
'recordsTruncated=true 时必须说明「只是最近的一部分」。',
'每条 record 里 outcome 只是其中一个字段,还有客服勾的子选项:abandonReasons(放弃原因,只有「放弃」才有)、inaccurateTreatments(客服说这条召回判断错了,调算法要看它)、scheduledNextAt(约的回访日)、channel(电话/企微/短信)。⛔ 只报 outcome 等于漏掉一半 ——「为什么放弃」的答案在 abandonReasons 里。',
'上面这些数用你自己的话讲,⛔ 别整段照搬工具返回的字符串。',
];
/** 批次列表(`list_assignment_batches`)的解读规范 */
export const BATCH_LIST_GUIDE = [
'「已处理」不等于「已成功」:含召回出池、被抑制、已结案三种,⛔ 别说成转化。',
'各批次的处理率不能直接横比,先看各自跑了多少天。',
'要看某一批为什么这样,用 get_assignment_detail 取明细,⛔ 不要从汇总数里推原因。',
];
...@@ -23,6 +23,16 @@ import type { TenantScopeContext } from '../../common/decorators/tenant-scope.de ...@@ -23,6 +23,16 @@ import type { TenantScopeContext } from '../../common/decorators/tenant-scope.de
import { resolveClinicId } from '../../common/decorators/resolve-clinic-id'; import { resolveClinicId } from '../../common/decorators/resolve-clinic-id';
import type { McpAuthContext } from './mcp-auth.service'; import type { McpAuthContext } from './mcp-auth.service';
import { PERSONA_TAGS_DESC, POTENTIAL_TREATMENT_DESC } from './persona-tags.desc'; import { PERSONA_TAGS_DESC, POTENTIAL_TREATMENT_DESC } from './persona-tags.desc';
// ⭐ 面向模型的翻译层 —— 取值码换中文、桶名换中文键。见 model-facing.ts 顶部注释:
// 给模型现成的中文(① 级),替代提示词里那两页「⛔ 不许说出取值码」(③ 级)。
import {
criteriaZh,
zhCohortDim,
zhProgressBuckets,
zhScenario,
} from './model-facing';
// ⭐ 解读规范随返回值下发,⛔ 不再常驻在工具描述里。见 guides.ts 顶部注释。
import { BATCH_LIST_GUIDE, TRACKING_GUIDE } from './guides';
function jsonResult(data: unknown) { function jsonResult(data: unknown) {
return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] };
...@@ -370,20 +380,25 @@ export class McpServerFactory { ...@@ -370,20 +380,25 @@ export class McpServerFactory {
keys: z.array(z.string()).optional().describe('只看这些维度(见描述里的维度清单)'), keys: z.array(z.string()).optional().describe('只看这些维度(见描述里的维度清单)'),
}, },
}, },
async ({ clinicId, potentialTreatment, temperature, anchorMode, personaTags, keys }) => async ({ clinicId, potentialTreatment, temperature, anchorMode, personaTags, keys }) => {
jsonResult( const r = await this.cohorts.describe(
await this.cohorts.describe( scope,
scope, {
{ clinicId: resolveClinicId(scope, clinicId),
clinicId: resolveClinicId(scope, clinicId), ...(potentialTreatment ? { potentialTreatment } : {}),
...(potentialTreatment ? { potentialTreatment } : {}), ...(temperature ? { temperature } : {}),
...(temperature ? { temperature } : {}), anchorMode: parseAnchorMode(anchorMode),
anchorMode: parseAnchorMode(anchorMode), ...(personaTags ? { personaTags } : {}),
...(personaTags ? { personaTags } : {}), },
}, { ...(keys ? { keys } : {}) },
{ ...(keys ? { keys } : {}) }, );
), return jsonResult({
), ...r,
// ⭐ 条件回显 —— 模型用码调工具,但要用中文回话。不给回显它就只能自己翻译。
criteriaZh: criteriaZh({ potentialTreatment, temperature, anchorMode }),
dimensions: r.dimensions.map(zhCohortDim),
});
},
); );
// ⛔ **`propose_assignment` 刻意不在这里注册** —— 它是 assistant 的**本地工具**。 // ⛔ **`propose_assignment` 刻意不在这里注册** —— 它是 assistant 的**本地工具**。
...@@ -396,14 +411,17 @@ export class McpServerFactory { ...@@ -396,14 +411,17 @@ export class McpServerFactory {
{ {
description: description:
'我分过的批次列表 + 每批汇总(分了多少 / 已处理 / 还在手 / 已退回 / 涉及几个客服)。' + '我分过的批次列表 + 每批汇总(分了多少 / 已处理 / 还在手 / 已退回 / 涉及几个客服)。' +
'\n⚠️ done 是**已处理**不是**已成功**(召回出池 / 被抑制 / 已结案),别说成转化。' + '\n回答「我分的那些批,哪批出问题了」。要看某批细节再用 get_assignment_detail。' +
'回答"我分的那些批,哪批出问题了"。要看某批细节再用 get_assignment_detail。', '\n⚠️ 返回里带 `_guide`,读数前先看它。',
inputSchema: { inputSchema: {
mine: z.boolean().optional().describe('只看自己发起的,默认看本范围全部'), mine: z.boolean().optional().describe('只看自己发起的,默认看本范围全部'),
}, },
}, },
async ({ mine }) => async ({ mine }) =>
jsonResult(await this.assignments.list(scope, mine ? scope.userId : undefined)), jsonResult({
batches: await this.assignments.list(scope, mine ? scope.userId : undefined),
_guide: BATCH_LIST_GUIDE,
}),
); );
server.registerTool( server.registerTool(
...@@ -435,42 +453,25 @@ export class McpServerFactory { ...@@ -435,42 +453,25 @@ export class McpServerFactory {
server.registerTool( server.registerTool(
'get_assignment_detail', 'get_assignment_detail',
{ {
// ⚠️ 这里只写「**怎么用这个工具**」—— 那是模型在决定调不调时要看的。
// 「**怎么解读这批数**」全部搬进返回值的 `_guide`(见 guides.ts 顶部注释):
// 原来那 35 行是常驻成本,而只有约三成会话用得到。
description: description:
'单个批次的全貌:处理进度 + 按客服拆 + 退回原因分布 + 未动过的条数。' + '单个批次的全貌:处理进度 + 按客服拆 + 退回原因分布 + 通话成效 + 逐条明细与电话纪要。' +
'\n⚠️⚠️ progress 是**处理率不是成功率** —— 只说"这单动过了",不说"谈成了"。' + '\n回答「我分的那批怎么样了 / 哪个患者为什么退回 / 客服怎么说的」。' +
'resolved=该患者召回已出池(引擎按客观事实判定需求已了) / suppressed=客服写了回访结果 / ' + '\n⚠️ 返回里带 `_guide` —— **读数之前先看它**,里面是这批数容易被误读的地方。',
'inHandPending=还在手上没动 / backToPool=退回或到期落回池子。' +
'⛔ 不要把 resolved 说成"转化成功",⛔ 不要拿这些数自己算转化率。' +
'\n⚠️ 报处理率**必须带批次年龄**(progress.ageDays):跑了三个月的批次天然比跑了三天的好看。' +
'\n⚠️ 退回率**永远给两个数**:「退回 5 / 已处置 40 = 12.5%(另有 60 条未动)」——' +
'"没人动"和"动了但退回"是完全不同的信号,只报一个百分比会让主管把前者误读成后者。' +
'\n⚠️ 分母小的时候(如 <50)直接说"样本量不足",**不要输出百分比、不要画图**。' +
'\n\n⚠️⚠️ `outcomes`(通话成效)与 `releaseReasons`(退回原因)是**两件不同的事**,⛔ 绝不能混说:' +
'\n · releaseReasons = 「这单**不该我做**」—— 客服**没打**就还回去了 → 是**分配**问题' +
'\n · outcomes = 「打了,结果这样」—— 客服**做了事** → 是**召回效果**问题' +
'\n 说反了主管会去改错的东西(一个要调派单、一个要调话术和人群)。' +
'\n⚠️ `outcomes.noOutcome`(一次结果都没有)**必须单独报出来**,' +
'⛔ 不许算进"不成功" —— 那不是效果差,是**根本没做/没记**。' +
'\n⚠️ `outcomes.success` 含**约定下次回访**(约到下次也算有效推进),' +
'⛔ 别说成"成交/转化了这么多"。' +
// 🔴 这里原来还有一句「照抄 `outcomes.note` 最稳」——2026-08-08 连同那个字段一起删了:
// note 里混着给模型的指令(「不要算成功率、不要画图」),模型照抄就把内部指令
// 原样贴进了主管的对话框。⛔ 别再造这种"成品句子"字段,护栏就写在本说明里。
'\n⚠️ 上面这些数**用你自己的话讲**,⛔ 别整段照搬工具返回的字符串。' +
'\n\n⭐ `outcomes.records` = **逐条明细 + 客服手写的电话纪要**(`notes`)。' +
'主管问"哪个患者/为什么/客服怎么说的",答案只在这里 —— 别只回聚合数。' +
'\n⚠️ `notes` 为 null = **没留纪要**(不是没打)。' +
'结果填了、纪要空着本身是信息:说明只点了个选项。' +
'\n⚠️ 引用纪要时**照原话**,⛔ 别润色成"客户表示…" —— 主管要看的就是客服当时怎么写的。' +
'\n⚠️ `recordsTruncated=true` 时必须说明"只是最近的一部分"。' +
'\n⚠️ 每条 record 里 `outcome` **只是其中一个字段**,还有客服勾的子选项:' +
'`abandonReasons`(放弃原因,只有「放弃」有)、' +
'`inaccurateTreatments`(客服说**这条召回判断错了**,调算法要看它)、' +
'`scheduledNextAt`(约的回访日)、`channel`(电话/企微/短信)。' +
'⛔ 只报 outcome 等于漏掉一半 —— "为什么放弃"的答案在 `abandonReasons` 里。',
inputSchema: { assignmentId: z.string() }, inputSchema: { assignmentId: z.string() },
}, },
async ({ assignmentId }) => jsonResult(await this.assignments.detail(scope, assignmentId)), async ({ assignmentId }) => {
const d = await this.assignments.detail(scope, assignmentId);
// ⭐ 五个桶换成中文键。⚠️ 「已出池·引擎判定需求已了」刻意写全 —— resolved
// 不是「谈成了」,键名本身就该挡住这个误读(T14)。
const p = d.progress as Record<string, unknown> | undefined;
return jsonResult({
...(p ? { ...d, progress: { ...zhProgressBuckets(p), note: p.note } } : d),
_guide: TRACKING_GUIDE,
});
},
); );
} }
...@@ -513,10 +514,13 @@ export class McpServerFactory { ...@@ -513,10 +514,13 @@ export class McpServerFactory {
}, },
}); });
if (!plan) return null; if (!plan) return null;
const reasons = await this.prisma.planReason.findMany({ const rawReasons = await this.prisma.planReason.findMany({
where: { planId: plan.id }, where: { planId: plan.id },
select: { scenario: true, priorityScore: true, reason: true }, select: { scenario: true, priorityScore: true, reason: true },
}); });
// ⭐ 场景码带上中文(`treatment_initiation_recall` → 「潜在治疗」)。
// ⚠️ 保留原 `scenario` 字段:模型有时要拿它当别的工具的参数。
const reasons = rawReasons.map((r) => ({ ...r, scenarioZh: zhScenario(r.scenario) }));
return { return {
planId: plan.id, planId: plan.id,
status: plan.status, status: plan.status,
......
import {
ANCHOR_MODE_META,
TEMPERATURE_META,
planScenarioLabel,
potentialTreatmentCardLabel,
type AnchorModeValue,
type TemperatureValue,
} from '@pac/types';
/**
* model-facing —— 服务层返回值 →「面向模型」的形状。
*
* ═══ 为什么要有这一层 ═══════════════════════════════════════════════
* 模型说出 `cold_3y` `implant` `inHandPending` 这类词,主管的第一反应是**系统坏了**。
* 原来的对策是在提示词里写两页「⛔ 不许说出取值码」——那是 P4 ③ 级(只拦得住预想到的),
* 而且实测拦不住:模型调工具**必须**用码当参数,回话时自然就带出来了。
*
* 真正的解法是**给它现成的中文**:返回值里既回显它传进来的条件(`criteriaZh`),
* 又把桶名换成中文键。它手里有话可说,就不会去说码。—— 这是 ① 级(结构上不可能)。
*
* ═══ 三条边界 ═══════════════════════════════════════════════════════
* ⛔ **只做翻译,不做业务判断。** 任何"要不要提醒""算不算成功"的判断都不属于这里。
* ⛔ **不改底层 service 的形状。** REST / 前端是另一个消费者,它们要的是稳定的 key。
* 翻译只发生在 MCP 边界上。
* ⛔ **不在这里生成成品句子。** 那是 P2 要消除的东西(G6:给事实不给句子)。
*
* 标签一律取自 `@pac/types` 的既有映射,⛔ 不在这里另立一套中文 —— 两份必然漂。
*/
// ─────────────────────────────────────────────────────────
// 单值翻译
// ─────────────────────────────────────────────────────────
/** 时间档取值码 → 矩阵上那一列的中文(`hot` → 「三个月内」) */
export function zhTemperature(code: string | null | undefined): string | null {
if (!code) return null;
return TEMPERATURE_META[code as TemperatureValue]?.zh ?? code;
}
/** 潜在治疗取值码 → 矩阵上那一行的中文(`implant` → 「种植」) */
export function zhTreatment(code: string | null | undefined): string | null {
if (!code) return null;
return potentialTreatmentCardLabel(code);
}
/** 召回场景取值码 → 中文(`treatment_initiation_recall` → 「潜在治疗」) */
export function zhScenario(code: string | null | undefined): string | null {
if (!code) return null;
return planScenarioLabel(code);
}
/** 档位口径 → 中文(`last_visit` → 「按末诊」) */
export function zhAnchorMode(code: string | null | undefined): string | null {
if (!code) return null;
return ANCHOR_MODE_META[code as AnchorModeValue]?.zh ?? code;
}
// ─────────────────────────────────────────────────────────
// 条件回显
// ─────────────────────────────────────────────────────────
/**
* 把模型传进来的筛选条件原样回一份中文 —— **这是本模块最关键的一个函数**。
*
* 模型必须用码调工具(`temperature: 'hot'`),但回话要说「三个月内」。
* 不给回显,它只能自己翻译;自己翻译就会翻错,或者干脆把码念出来。
* ⇒ 每个吃这些条件的工具,返回值里都带一份 `criteriaZh`。
*
* [弥补模型]
*/
export function criteriaZh(input: {
potentialTreatment?: string | null;
temperature?: string | null;
anchorMode?: string | null;
}): Record<string, string> {
const out: Record<string, string> = {};
const t = zhTreatment(input.potentialTreatment);
const temp = zhTemperature(input.temperature);
const anchor = zhAnchorMode(input.anchorMode);
if (t) out['治疗项'] = t;
if (temp) out['时间档'] = temp;
if (anchor) out['口径'] = anchor;
return out;
}
// ─────────────────────────────────────────────────────────
// 桶名翻译
// ─────────────────────────────────────────────────────────
/**
* 批次处理进度的五个桶 → 中文键。
*
* ⚠️ 这几个词是提示词里被反复叮嘱的重灾区(`resolved` `suppressed` `inHandPending`
* `backToPool`),因为它们既是内部枚举、又必须报给主管看。换成中文键之后,
* 模型照着键名说就是对的。
*
* ⚠️ 中文键刻意写全「已出池」而不是「已完成」—— `resolved` 是**引擎判定召回需求没了**,
* ⛔ 不是「谈成了」。键名本身就该挡住这个误读(T14)。
*
* [弥补模型]
*/
export function zhProgressBuckets(p: {
done?: number;
resolved?: number;
suppressed?: number;
closed?: number;
inHandPending?: number;
backToPool?: number;
reassigned?: number;
ageDays?: number;
}): Record<string, number> {
const out: Record<string, number> = {};
if (p.done !== undefined) out['已处理'] = p.done;
if (p.resolved !== undefined) out['已出池·引擎判定需求已了'] = p.resolved;
if (p.suppressed !== undefined) out['客服已写回访结果'] = p.suppressed;
if (p.closed !== undefined) out['已结案'] = p.closed;
if (p.inHandPending !== undefined) out['还在客服手上没动'] = p.inHandPending;
if (p.backToPool !== undefined) out['退回或到期落回池子'] = p.backToPool;
if (p.reassigned !== undefined) out['已被后续批次挑走'] = p.reassigned;
if (p.ageDays !== undefined) out['本批已跑天数'] = p.ageDays;
return out;
}
/**
* 画像维度里的 `noTag` → 中文键。
*
* ⚠️ 「没有这条记录」而不是「无标签」:`noTag` 是**没有这条画像证据**,
* ⛔ 不是反面(「没有商保标签」≠「自费」)。这是提示词里最长的一条护栏,
* 键名写清楚就少一次误读。
*
* [弥补模型]
*/
export function zhCohortDim<T extends { id: string; nameZh: string; noTag: number; multi: boolean }>(
dim: T,
): Omit<T, 'noTag' | 'multi'> & { 没有这条记录的人数: number; 一人可命中多项: boolean } {
const { noTag, multi, ...rest } = dim;
return { ...rest, 没有这条记录的人数: noTag, 一人可命中多项: multi };
}
...@@ -347,6 +347,19 @@ export function useAssistantChat() { ...@@ -347,6 +347,19 @@ export function useAssistantChat() {
return blocks.map((x, i) => (i === realIdx ? updated : x)); return blocks.map((x, i) => (i === realIdx ? updated : x));
}); });
break; break;
case 'step_limit':
/**
* 🔴 单轮工具步数到顶(服务端 stopWhen 截断)。
*
* ⚠️ 必须让主管看见:模型可能停在「我先查一下」之后就没了下文,
* 而他以为查完了 —— 这跟「说了已撤销但没调工具」是同一类静默失败。
* ⚠️ 只陈述事实 + 给一个明确的下一步,⛔ 不自动续跑(续不续是他的决定)。
*/
appendText(
`\n\n⚠️ **这一轮查到上限了**(${String(evt.limit ?? '')} 步),助手可能还没查完。` +
`若上面的回答不完整,请说一句「接着查」。`,
);
break;
case 'error': case 'error':
appendText(`\n\n⚠️ 出错:${String(evt.error)}`); appendText(`\n\n⚠️ 出错:${String(evt.error)}`);
break; break;
......
# Agent 设计基础
> 通用理论,与具体业务无关。出现的业务例子只为帮助理解,不构成实现约定。
> 目的:在动手设计任何一个 agent 之前,先有一套判断手段的共同标准。
| | |
|---|---|
| **状态** | 理论基础(Foundation) |
| **适用** | 任何以 LLM 为决策核心、通过工具与系统交互的 agent |
| **不覆盖** | 具体业务流程、模型选型、成本核算、UI 形态 |
| **用法** | 后续业务设计文档可直接引用本文编号(如「依 P4」「见 §6」) |
---
## 〇、总纲
> **模型负责决策,环境负责事实。**
让模型自主决定「下一步做什么」——这是 agent 的全部价值,不要用流程编排把它替换掉。
但它报出的每个事实,都必须**指得到来源**:来自某次工具返回,而不是来自它自己。
这两句不矛盾:给模型一把计算器、让它自己决定何时使用,比替它规定调用顺序**更自主**,也更可靠。
> ⚠️ 这条的判据是「**有没有锚**」,不是「能不能算」。
> 模型的算力在快速变强,但「引用了没有来源的数」「跨轮沿用旧条件下的数」跟算力无关 —— 那是上下文机制的性质,不会随模型变强而消失。
---
## 一、Agent 是什么
**定义:一个在受限上下文中、通过动作改变环境、依靠反馈收敛的循环。**
```
┌──────────────────────────────────────────────┐
│ 上下文 │ ← 控制面 I
│ 系统提示词 · 对话历史 · 工具结果 · 任务状态 │
└───────────────────────┬──────────────────────┘
[ 模型 ]
文本 ──────────→ 给人看
动作 ──┐
┌──────────────────────────────────────────────┐
│ 动作空间 / 工具 │ ← 控制面 II
└───────────────────────┬──────────────────────┘
┌──────────────────────────────────────────────┐
│ 环境(DB · 文件 · 外部系统) │
└───────────────────────┬──────────────────────┘
反馈 ───────┘ ← 控制面 III
└────→ 回到上下文,进入下一轮
控制面 IV(权限 · 人工闸门 · 回滚 · trace)横跨全程
```
**三个必须认清的事实:**
1. **「要不要调工具」和「下一个字写什么」是同一个动作。** 没有独立的决策层,工具调用只是一段结构化输出。因此**没有任何机制保证模型该调用时一定会调用**
2. **没有反馈的工具调用不是 agent**,只是带副作用的补全。反馈回路是 agent 区别于一次性生成的本质。
3. **上下文每轮完整重建,且单向污染。** 错误信息进去后不会自行消失。
---
## 二、四个控制面
设计 agent,能动的只有四样东西。所有流行概念都是其中某一面的实现。
| | 控制面 | 决定了 | 典型投入不足程度 |
|---|---|---|---|
| **I** | **上下文** — 模型唯一的输入 | 它知道什么 | 中 |
| **II** | **动作空间** — 模型唯一改变世界的通道 | 它能做什么 | 低(大家都在堆工具) |
| **III** | **反馈回路** — 环境给它的真话 | 它能否自我纠正 | **高(最常被忽略)** |
| **IV** | **控制与恢复** — 谁能停、谁能撤 | 出错的代价 | 中 |
| **贯穿** | **评测** — 是否在变好 | 能否长期维护 | **高** |
> **四面不是互斥分区,是四个提问角度。** 同一段代码可以同时出现在两面甚至三面(例:工具的返回值同属 II 与 III;trace 同属 III 与 IV)。
> 分面的价值在于**检查时不漏项**,不在于分类学的纯洁。删掉任何一面,都会整类地漏掉该问的问题。
### 控制面 I · 上下文
**性质**:稀缺、每轮重建、单向污染。窗口变大**不解决**污染,反而更糟——没有任何力量逼你清理。
**手段**
- **摘要 + 指针**:工具返回 `{id, 摘要}`,明细留在库里,需要时再取(详见下)
- **状态外置**:长任务的进度与中间产物放持久层,上下文只是本轮工作台
- **渐进披露**:知识分层,用到才加载
- **只读子 agent**:需要翻大量材料时派出去翻,只带结论回主上下文(**这是多 agent 唯一无争议的用途**
- **上下文卫生**:失败的工具调用只留错误摘要,丢弃原始垃圾输出(**应在源头做,见 §7⑤**
**判据:按量级决定,不要按类别一刀切。**
| 量级 | 策略 |
|---|---|
| 几十条 / 几千 token 以内 | **给原文** |
| 几十到数百 | 结构化摘要 + 指针 |
| 数百以上 | 只给数量 + 指针,明细走界面 |
| **量级未知** | **先取数量,再决定取不取** |
> ⚠️ 别默认「摘要总是更好」。摘要是**有损**的,而且损失的往往正是后面要追问的那部分;
> 原文还有两个隐性好处:追问时不必再取一次,以及**可以被重新解读**(同一份材料,下一个问题看的是完全不同的角度)。
> 「先数量后明细」这条应当落进工具设计:**每个列表类工具都要有「只回数量」的轻量对应物。**
**明细留在库里的理由,按重要性**:① 上下文只进不出,放进去的东西之后每一轮都在场、都在争注意力——指针不是为了省地方,是**让数据有「离开」的机会**;② 在上下文里模型就会去引用它、基于它计算,而这恰是它不可靠之处;③ 上下文里是快照,指针取回的是当前值;④ 成本与延迟;⑤ 跨会话与多消费者(UI 不读上下文)。
#### 摘要 + 指针必须成对
摘要是**索引**,不是原文的替代品。类比 `ls``cat`——不会把整块磁盘读进内存再决定看哪个文件。
摘要要可用,必须携带**可展开信号**,且它分布在两个位置:
| 信号 | 所在位置 | 例 |
|---|---|---|
| **id** | 上一个工具的**返回值** | `{cohortId: "c_123"}` |
| **数量** | 上一个工具的**返回值** | `{total: 420}` |
| **可取维度** | 下一个工具的 **schema** | `取明细(cohortId, filter?, page?)` |
| **数据决定的维度** | 返回值(schema 写不死) | `{doctors: ["张","李","王"]}` |
缺任一项即退化:有 id 无取用工具 → 拿不到;有工具无 id → 不知取哪批;无数量 → 无法判断该不该展开。
**「追问都有谁」不是这个模式的破绽,而是它的正常路径**,且通常只需取一部分(「李医生那几个」→ 带筛选取 5 条,而非预先塞入 400 条)。
若答案是要给人看的名单,最优路径根本不经过模型:工具返回 `forUI`,前端渲染,模型只回一句「已列出」。
### 控制面 II · 动作空间
**手段(按杠杆从高到低)**
| 手段 | 说明 |
|---|---|
| **工具描述即提示词** | 同一条规则写在工具描述里,遵守率高于写在系统提示词里——它出现在决策点上。**能下沉到 schema 的规则就别留在 prompt 里** |
| **错误消息即提示词** | 报错要写成模型能据此纠正的形态。`参数无效,可用值 [...],不确定请传 UNSPECIFIED``400 Bad Request` |
| **枚举穷尽 + 兜底格** | 缺一个合法取值,模型必然猜、且静默。永远保留 `UNSPECIFIED`,服务端对它的处理是**反问**而非报错 |
| **参数即攻击面** | 模型不传就不可能出错的参数,不该是参数(见 §6) |
| **幂等 + 事务边界** | 模型会重复调用(重试、误判失败),写工具必须带幂等键 |
| **粒度 = 用户会说的一句话** | 太细则模型要自己编排,太粗则失去灵活性 |
| **三路返回值** | `forModel`(极简)/ `forUI`(完整结构化)/ `trace`(审计) |
| **高频专用 + 长尾兜底** | 专用工具的名字就是意图,选错空间小;但没有兜底,未预见的需求只能回「做不到」。兜底工具的参数必须是**枚举化的维度**,⛔ 不能是自由查询语句 |
| **`_guide` 随返回值下发** | 只在特定场景才需要的解读规范,挂在该工具的返回值上,⛔ 别常驻在工具说明或提示词里。这是按需加载最便宜的形态:零往返、必然到达、用不到时零成本 |
#### 附:怎么保证「该调的时候真的调」
§1 已述——没有任何机制天然保证模型该调用时会调用。按强度排:
| | 手段 | 强度 |
|---|---|---|
| ① | **数据不在上下文里** | **最强(P4 ①级)**。它想不调都编不出来,因为凑答案的材料不存在 |
| ② | **`tool_choice` 强制本轮必须调(或指定调哪个)** | 强。硬约束,不依赖自觉 |
| ③ | **结构化输出强制 schema** | 强。本质同 ② |
| ④ | **出口校验 + 驳回重试** | 中。检测到「期望工具调用却只有文本」则重来;重试是追加,不破坏缓存 |
| ⑤ | **提示词写「必须先查再答」** | 最弱 |
②③ 的前提是**你事先知道这一轮必须调工具**,而 agent 的价值恰在于它自己判断——所以只适合明确节点(如「用户已点确认,本轮必须提交」),不能一刀切。
**并且:模型不调工具经常是对的**(闲聊、追问已有信息、该停下来问人)。
所以目标不是「保证每轮都调」,而是**让它不调时无法产出看似正确的答案**——即 ①。
**怎么发现「该调没调」**:离线靠 golden set 断言工具序列(P8);在线可埋一个粗检测——本轮输出含数字或名单,却整轮未调用任何数据类工具 → 告警。
### 控制面 III · 反馈回路
**核心命题:给模型验证手段,比给它规则有效得多。** 而且这是**给能力**,不削弱自主性。
> **III 在实现上大部分寄生于 II** —— 下列工具都住在动作空间里,走同一条 tool-call 通道。
> 单列一面,是因为它问的是不同的问题:**II 问「它能做什么」,III 问「它做完之后知不知道结果」。**
> 实际价值:列工具清单时人人都会想到「圈定、提案、提交」,**没人会自动想到「提交完怎么验证」**。
**并非所有反馈都是新工具**——很大一部分是既有工具的返回值设计:
| 反馈来源 | 是新工具吗 |
|---|---|
| 写工具自身的返回值(成功/失败/实际影响了什么/冲突在哪) | ❌ 是**返回值设计** |
| 错误消息 | ❌ 同上,且是最高频的反馈 |
| 被动信号(超时、权限拒绝、schema 校验失败) | ❌ 环境推送,模型未主动调 |
| 人的动作(在确认单上删掉了 3 个人) | ❌ 极强的信号,但不是工具 |
| dry_run / explain / validate / read-back | ✅ 是 |
所以「加强反馈」的第一步通常不是加工具,而是**把现有工具的返回值与错误消息写好**
**标准四件套**
| 工具 | 作用 |
|---|---|
| `dry_run` | 看将要发生什么,不落库 |
| `explain(实体)` | 确定性引擎给出理由,模型只能查、不能编 |
| `validate(方案)` | 冲突检测,返回可读的纠正信息 |
| **read-back** | 提交后能查到真实结果,而不是假设成功 |
**补充手段**
- 自洽采样:关键决策跑 N 次比对,不一致则交人。只用在关键点,成本可控
- 对抗校验:第二次调用专门去**反驳**第一次的结论。对解释类输出有效,数值类应当用程序校验
**判据**:每加一个写工具,问一句「模型怎么知道它做对了」。答不上来就先别加。
#### III 与 IV 的分界
> **如果模型完全不配合——不读反馈、乱调工具、张口就编——哪一面还有效?**
| | 服务对象 | 模型不配合时 |
|---|---|---|
| **III 反馈** | **模型**(知道结果 → 自纠) | **全部失效**。前提是模型会读、会改 |
| **IV 控制与恢复** | **人 / 系统**(不发生、被看见、能撤销) | **完全有效**。权限拒绝、人工闸门、回滚都不征求模型意见 |
**III 是协作式的,IV 是强制式的。** 对到 P4:III 大体停在 ③ 级,IV 才是 ①② 级。
反馈还有一路的接收者根本不是模型:
| 路径 | 接收者 | 闭环在哪 |
|---|---|---|
| 返回值 / 错误 → 上下文 | 模型 | 会话内自纠 |
| trace / 评测 / 离线分析 | **开发者** | 版本之间改设计 |
### 控制面 IV · 控制与恢复
**目标不是不出错,是让出错变便宜。**
- **写屏障**:不可逆动作必须经人确认
- **回滚**:批量动作可整体撤销
- **trace + 确定性重放**:存下每次运行的工具序列并能重放——这是调试与评测的**前置基础设施**,先有它才有后面一切
- **权限隔离**:只读与可写工具分离;身份相关参数由会话解析
- **灰度**:新提示词 / 新 schema 先在小比例流量上跑
- **提示词与工具 schema 同版本**:schema 变更必须带着 prompt 一起走,否则漂移是必然
---
## 三、原则
| 编号 | 原则 | 要点 |
|---|---|---|
| **P1** | **先分控制面,再选手段** | 概念先归位,再问「我这一面的问题有多严重、有数据吗」 |
| **P2** | **模型决策,环境供事实** | 凡是能被计算或查询的,不经模型产出 |
| **P3** | **能力靠工具与反馈,可靠靠结构** | 提示词是最弱的杠杆,永远最后用 |
| **P4** | **保证强度分三级** | ① 让错误不可能 ≫ ② 让错误可见可撤 ≫ ③ 校验规则(见 §4) |
| **P5** | **区分「弥补模型不足」与「业务本身要求」** | 前者随模型变强应当拆除,后者永远保留。**每条措施都要标明属于哪类**,否则三年后没人敢删任何东西 |
| **P6** | **防御有成本,少而狠** | 规则越多,单条遵守率越低。堆防御会直接削弱你想发挥的能力 |
| **P7** | **安全边界不能靠提示词** | 身份是会话的属性,由登录态决定,不由模型或提示词决定(见 §6) |
| **P8** | **没有评测,质量是随机游走** | 判定标准是**工具调用序列与参数**,不是文字 |
### P6 的具体代价
| 防御手段 | 副作用 |
|---|---|
| 提示词堆到几十条规则 | 遵守的条数反而下降,新规则挤掉旧规则 |
| 硬性输出模板 | 模型转向「填格子」,不再思考内容 |
| 按阶段限制可用工具 | 走错一步后**无法自纠**,因为纠错工具被藏起来了 |
| 输出不合格就自动重试 | 掩盖问题,让你看不到真实失败率 |
---
## 四、保证的三个等级
```
▲ 强
│ ① 结构上不可能 改的是结构,不依赖模型配合
│ 例:数字不进上下文 → 不可能复述错
│ 身份不做参数 → 不可能越权
│ ② 可见 · 可撤 不阻止错误,把事故降级为噪音
│ 例:回滚、diff 确认、trace
│ ③ 校验 · 规则 只拦得住你**预想到**的错误
▼ 弱 而漂移与幻觉的定义就是「以你没想到的方式出错」
```
**推论**:一个措施如果只能靠 ③ 实现,通常说明上游的工具设计有问题。
---
## 五、规则放哪:五个容器
同一条「模型该怎么做」的知识,有四个可能的落点。**优先级从高到低:**
| 优先级 | 容器 | 何时用 | 成本 |
|---|---|---|---|
| 1 | **引擎代码** | 规则是确定性的 | 零上下文成本,模型无需知道 |
| 2 | **工具返回值** | 引导「下一步」、动态提示 | 用完即弃,出现在决策点上,改动最便宜 |
| 3 | **工具描述** | 该工具专属的约束 | 只在工具清单里付一次费 |
| 4 | **按需加载的知识包** | 大量、低频、分支性知识 | 不加载时为零 |
| 5 | **系统提示词** | 每一轮都必须为真的东西 | **最贵——所有会话所有轮次都付费** |
> 注意:KV 缓存能抵掉系统提示词的**算力**成本,抵不掉它的**注意力**成本。
> 「反正命中缓存了,多写几条无所谓」是错的——P6 的代价与缓存无关。
### 判断顺序
**第一刀:这是规则还是判断?**
> 把这段话删掉,模型行为变得**不可预测**(而非仅仅变差)→ 它是规则,进代码。
> 删掉后模型仍能工作、只是变糙 → 它是提示词。
例:「A 类优先分给甲」删掉后结果不确定 → 规则,进引擎。
  「拿不准就问,不要猜」删掉后只是变糙 → 提示词。
**第二刀:常驻还是按需?**
> **渐进披露的收益 = 不加载的概率 × 体积。**
> 概率接近 0 时收益为负——白搭一次往返、多一个失败点。
> **主线任务常驻,分支知识按需。**
**第三刀:加载方式与管理方式可以拆开。**
「独立文件、可 review、可版本化、可灰度」和「按需加载」是两个独立属性。
业务规则变更快,**可以只要前者**:管理上独立成文件,加载上仍然常驻。
---
## 六、身份、角色与「多 agent」
### 判据
```
两个角色之间有安全边界吗?
(即:能看到的数据 / 能做的动作不同)
┌───────────────┴───────────────┐
是 否
│ │
┌───────┴────────┐ 这只是话题差异,同一会话即可
│ 独立会话 │ │
│ 独立工具清单 │ 量大且低频?
│ 独立数据范围 │ ┌─────────┴─────────┐
│ (登录态决定) │ 是 否
└────────────────┘ │ │
按需加载 常驻提示词
```
### 为什么同一会话不能切换身份
**上下文是单向的。** 高权限会话中已载入的数据,不会因为一句「你现在是低权限角色」而消失。**提示词不是删除操作,也从来不是安全边界(P7)。**
### 「两个 agent」这个说法要拆开
| 维度 | 是否需要分 |
|---|---|
| 会话 | **必须分** |
| 工具清单 | **必须分**(由登录态下发,看不见比看见被拒更安全) |
| 数据范围 | **必须分**(下推到查询条件,而非查全量再过滤) |
| 系统提示词 | 分(但这是其中最不重要的一条) |
| 模型 | 不用分 |
| 代码实现 | 不用分 |
**结论:同一套 agent 实现,按身份参数化。** 这不是「多 agent 编排」——编排指 agent 之间互相调用;不同身份之间**不需要通信**,需要协作时走业务对象,不走 agent 消息。
### 身份与权限的四条硬规则
1. **身份随每次调用传递,不随连接建立。** 一个连接可能服务多个用户。
2. **授权在工具内部执行,不在 agent 层。**
3. **数据范围下推到查询语句。** 「查全量再过滤」漏一处就泄露;「条件里就带范围」漏一处只是查不到——**失败方向不同**
4. **工具清单本身按身份下发。**
> 判据:**如果模型不传某个参数,越权就不可能发生——那这个参数就不该是参数。**
> 顺带的好处:模型不用猜,少一个必错的空格。
---
## 七、上下文工程的五个维度
前四维描述**单轮里放了什么**,第五维描述**跨轮之间怎么变**
| | 维度 | 手段 |
|---|---|---|
| ① | **放什么**(选择) | 渐进披露、摘要 + 指针、按需取 |
| ② | **不放什么**(排除) | 明细不入、失败结果只留摘要、状态外置 |
| ③ | **放在哪**(位置) | 注意力不均匀,长上下文中段利用最差;当前任务状态应靠近末尾 |
| ④ | **以什么形式放**(表示) | 同一份数据用 JSON / 表格 / 散文,模型的理解与引用准确度不同。**结构化数据用紧凑结构,判断依据用自然语言,不要混** |
| ⑤ | **什么时候变**(稳定性) | KV 缓存友好性:**只追加、不修改**,动态内容后置 |
多数团队只做了 ① 和 ②。**④ 几乎零成本;⑤ 直接决定成本与延迟的数量级。**
### ③ 的边界:能控制的位置有限
一次请求由三块构成,落点由服务端排定:`system``tools``messages`
**你能调整顺序的只有 `system` 内部与 `messages` 内部**`tools` 的位置不归你管。
推论:**工具定义天然占据靠前且固定的位置**——这是「规则下沉到工具描述比留在系统提示词里有效」的机制之一(另见 §5)。
### ⑤ KV 缓存:最常被忽略的一维
**机制**:缓存按**前缀**匹配。上下文从头到某处完全一致,这一段的计算即可复用;**任何一个 token 变化,从该处往后全部失效**
Agent 的特征恰恰是「同一个长前缀被反复发送几十轮」,所以命中与否是**数量级**的差距,延迟与成本同时受影响。
**由此推出的硬约束:按稳定性排序,越稳定越靠前。**
```
┌────────────────────┐
稳定 │ 系统提示词 │ 几乎不变
│ 工具定义 │ 按身份固定
│ 长期知识 / 技能包 │ 半稳定
│ 对话历史 │ 只追加
易变 │ 当轮输入 │ 每次变
└────────────────────┘
↑ 命中止于第一个变化点,其后全部重算
```
**四条实操规则**
1. **系统提示词里不要放动态内容。** 一个当前时间戳、一个随机 id,就足以让整个前缀每轮失效——最常见也最昂贵的坑。动态内容一律后置到 `messages` 末尾。
2. **工具清单要稳定。** 按身份下发是对的(同一身份内固定),但**不要每轮动态增删工具**。这也是不推荐「按阶段限制可用工具」的第二个理由(第一个是堵死自纠,见 P6)。
3. **只追加,不修改历史。**
4. **不要重排历史。**
### 一个真实张力:上下文卫生 ⇄ 缓存友好
| | 目标 | 手段 |
|---|---|---|
| 上下文卫生 | 别放脏东西 | 清理历史中的垃圾 |
| 缓存友好 | 别改已放的东西 | 历史只追加 |
**二者在「去噪」这件事上直接冲突**:在第 k 条消息上动手术,第 k 条之后的缓存全部作废。
**解法:在源头治理,不在历史上治理。** 工具返回时就只给摘要形态(错误只回错误码与原因,不回原始堆栈),历史从一开始就是干净的,此后无需再改。
> 这条同时解释了为什么「摘要 + 指针」优于「先全量塞入、事后清理」:
> 前者天然满足只追加,后者必然要修改历史。
### 多轮:必须带全脉络,但要分层带
多轮对话丢掉工具调用历史,模型下一轮就不知道自己查过什么、拿到过什么——它会重复调用,也会拿上一轮**自己说过的话**当唯一依据(那正是「背出成品句子」的温床)。
所以「带全」和「别污染」不是二选一,是分层:
| 内容 | 回传什么 |
|---|---|
| 工具调用 | 工具名 + 关键参数 + **结果摘要** |
| 大载荷(名单、明细) | id + 一句话 |
| 失败 | 错因一句,⛔ 不带原始堆栈 |
这就是「摘要 + 指针」用在历史上。
### 去噪 ≠ 压缩
| | 靠什么 | 成本 | 何时做 |
|---|---|---|---|
| **去噪** | 规则 / 纯代码 | 极低、无损 | 现在,且应在源头 |
| **压缩** | 模型总结 | 高、有损、破坏缓存 | 长任务出现后再说 |
**组装上下文的是代码,不是模型。** 每轮 `messages` 里放什么完全由代码决定,去噪不需要任何模型参与(P2 的一个实例);只有「压缩 / 总结」才必须动用模型,因为它需要理解语义。
长任务的正解也不是压缩历史,而是**状态外置**——进度写入持久层,历史丢弃亦可继续。
### 与提示词工程 / harness 的关系
- **提示词工程 ⊂ 上下文工程。** 系统提示词就是上下文里最固定的一块。这解释了为什么规则会互相挤——它们在争同一个资源。
- **harness ⊄ 上下文工程。** harness 在上下文之外,作用于动作与后果(控制面 II / III / IV 中「弥补模型不足」的部分)。
### 关于窗口大小
大窗口消除的是**容量**问题,不消除:
- **污染**——错误信息不会自行离开
- **注意力衰减**——放得下 ≠ 用得好
- **成本与延迟**——每轮重发全部上下文,多轮是二次增长
- **缓存失效的代价反而更大**——前缀越长,一次失效要重算的越多。窗口越大,⑤ 越重要
---
## 八、外部记忆
**它解决的唯一问题:没有 schema 的事实。**
| 事实类型 | 存放 | 谁来判定写入 |
|---|---|---|
| 有 schema 的(偏好、配置、规则) | **持久层 + 工具读写** | 明确的业务规则 |
| 无 schema 的(任意形状的经验) | 文件式记忆 | 模型自己——**因此难以信任** |
**判据**:当你发现有东西「想记但没地方存」,那才是外部记忆的信号。而这通常说明你缺一张表——**先考虑建表**
如果系统面对的是一个 schema 已知的世界,「记忆」这件事已被持久层完整吸收,不需要额外机制。
**无论存在哪里,三件事缺一不可:**
1. **写入门槛** —— 模型只能提议,落库由规则或人确认。让模型自由写入「我学到了 X」,会把一次偶然固化成规则,且**没有 diff 可看**
2. **可见可删** —— 看不见的个性化是最难排查的 bug 来源
3. **失效策略** —— 带时间戳与来源,读出时告知「这是何时记录的」
**反模式:经验型记忆。** 「上次这样做效果好,以后都这样」——不可调试,出问题时无法定位是哪条记忆学坏的。正确做法是把它变成**有 schema 的规则记录**
---
## 九、流行术语甄别
| 术语 | 归属 | 判定 | 说明 |
|---|---|---|---|
| **工具 / function calling** | II | **地基** | 不是可选项。一切能力最终归结为工具调用 |
| **MCP** | II 的**封装形式** | **看入口规划** | 对模型而言 MCP 工具与原生工具**完全无法区分**,所有工具设计原则原样适用。它解决的是「工具跨进程、跨 host 复用」。**单一入口时是净开销;计划开放能力或接入外部生态时应提前统一**——这是接口决策,改期成本随时间指数上升。**真正的代价在鉴权(见 §6)**,而非多一层 |
| **Skills / 技能包** | I | **拆开用** | 捆了两件独立的事:独立文件管理、按需加载。**可以只要前者**(§5 第三刀)。核心思想是渐进披露,不必拘泥形式 |
| **记忆** | I | **必须拆三类** | 会话内状态 / 用户偏好 / 学到的经验。第三类现阶段不建议做(§8) |
| **多 agent 编排** | I + 并行 | **限用** | 它解决的是**上下文隔离与并行**,不是「智能不够」。唯一无争议的用法是只读子 agent。按角色分不属于编排(§6) |
| **RAG / 向量检索** | I | **看数据形态** | 解决「海量**非结构化**文本中找相关片段」。对**结构化数据**使用向量检索是退化:更慢、更不准、无法解释、无法精确过滤——查询语言本身就是检索手段。即便面对自由文本,也应先试全文检索,向量是最后手段 |
| **反思 / 自我批评循环** | III | **需外部反馈才有效** | 没有环境真话的自我批评收益很小,容易自我确认 |
| **思维链 / 推理** | — | **有效但未经验证** | 提高正确率,但它仍然只是 token,没有被任何东西检验 |
**统一筛子——任何手段进门前三问:**
1. 它解决的是哪个控制面的问题?
2. 我这个问题现在有多严重?**有数据吗?**
3. 有没有更便宜、更结构性的解法?
第 2 问答不上来,就是在赶时髦。
---
## 十、设计检查清单
> 这是**过一遍**用的,不是**全打勾**用的。每项的必要性与时机见 §11。
### 动作空间
- [ ] 每个工具的粒度约等于用户会说的一句话
- [ ] 所有枚举穷尽合法值,且含 `UNSPECIFIED` 兜底
- [ ] 身份 / 范围类参数由会话解析,**不出现在 schema 中**
- [ ] 写工具幂等,带幂等键
- [ ] 错误消息写成「模型可据此纠正」的形态
- [ ] 返回值分离 forModel / forUI / trace
- [ ] 能下沉到工具描述的规则,已从系统提示词移出;只在特定场景需要的解读规范下沉到 `_guide`
- [ ] 高频场景有专用工具,长尾有一个**枚举化参数**的兜底工具
- [ ] 每个列表类工具都有「只回数量」的轻量对应物
- [ ] 给模型的是**结构化事实**,不是成品句子
### 反馈
- [ ] 每个写工具都有对应的验证手段(dry_run / validate / read-back)
- [ ] 解释类问题由确定性来源回答,模型不得自行编写理由
### 上下文
- [ ] 明细**按量级**决定给原文还是给 id + 摘要;量级未知时先取数量
- [ ] 摘要携带完整的**可展开信号**(id + 数量 + 可取维度)
- [ ] 多轮会话带全脉络,且工具历史是**分层**回传(名字 + 参数 + 结果摘要)
- [ ] 任务状态存在于上下文之外
- [ ] 失败的工具结果**在源头**就只返回错误摘要(而非事后清理历史)
- [ ] 工具返回值的**表示形式**经过设计(结构化 vs 自然语言)
### KV 缓存
- [ ] 系统提示词内**无动态内容**(时间戳、随机 id、每轮变化的状态)
- [ ] 同一身份的工具清单在会话内固定,不逐轮增删
- [ ] 历史只追加,不修改、不重排
- [ ] 上下文按稳定性排序(稳定在前,易变在后)
### 控制与恢复
- [ ] 不可逆动作有人工闸门
- [ ] 批量动作可回滚
- [ ] 全链路 trace 可重放
- [ ] 提示词与工具 schema 同版本发布
- [ ] **每条 harness 措施标注了属于「弥补模型」还是「业务要求」(P5)**
### 评测
- [ ] 存在固定的回归集,断言**工具调用序列与参数**
- [ ] 改提示词 / 改 schema / 换模型前后都会跑
---
## 十一、优先级与裁剪
> **本文是检查清单,不是施工图。** 每一条的正确用法是「问一遍」,不是「都做」。
> 不做某一条完全可以——**但要知道自己没做,以及缺口在哪**。
> 全套照做就是过度设计,代价不只是工时:**过早的抽象会锁死你还没理解的问题。**
### 分档依据:后补代价
| 档 | 后补代价 | 何时做 |
|---|---|---|
| **一** | 重构(要动所有工具 / 是安全边界 / 不做就是盲的) | **开工前必须定** |
| **二** | 补一段代码 | 随功能增量做 |
| **三** | 无(不做也没缺口) | **等信号再做**,提前做即过度设计 |
### 第一档 · 地基
| 项 | 后补为什么疼 |
|---|---|
| **P2 模型不产出可计算的事实** | 要改所有工具的返回值形态 |
| **身份 / 范围由会话解析,不做工具参数** | 安全边界,后改是重构 |
| **摘要 + 指针**(返回值形态) | 同上,改的是所有返回值 |
| **写屏障**(不可逆动作过人) | 业务要求,不是防御 |
| **trace + 可重放** | 没有它,后面所有调试与评测都是盲的 |
| **系统提示词内无动态内容** | 一行代码的事,不做则每轮白烧一次全量计算 |
这一档成本都不高,但**都是接口决策**
### 第二档 · 随功能增量做
工具描述与错误消息打磨 · 枚举兜底格 · 写工具配验证手段(按需,不必四件套齐全)· 幂等键 · 回滚 · 评测集(可从 5 条起步)· 上下文按稳定性排序 · 返回值表示形式
**原则:每加一个写工具,同时回答「模型怎么知道它做对了」——但答案可以只是「返回值里带上实际影响」,不必是一整套工具。**
### 第三档 · 等信号
| 手段 | 触发信号 |
|---|---|
| 只读子 agent | 单次任务的材料已挤占主上下文,且这些材料只为得出一个结论 |
| 上下文压缩 | 任务生命周期确实长过一个窗口,且**状态外置已做**仍不够 |
| 按需加载知识包 | 某类知识体积大,且半数以上会话用不到 |
| 外部记忆 | 出现「想记但没有表可放」的事实(先考虑建表) |
| 自洽采样 | 某关键决策的不一致率**已被测出来** |
| 对抗校验 / judge | 解释类输出被发现编造,且程序无法校验 |
| `tool_choice` 强制 | 存在明确的「本轮必须调某工具」节点 |
| 在线埋点告警 | 已有 golden set,且线上出现过凭空编造 |
| MCP | 出现第二个入口,或已列入能力开放计划 |
| RAG / 向量 | 检索目标确实是非结构化文本,且全文检索已试过不够 |
### 投入顺序
```
1. trace + 重放 ← 没有它,后面全是盲调
2. 工具设计(II) ← 杠杆最大:描述、错误消息、枚举、参数面
3. 反馈(III) ← 先做返回值,再考虑专门的验证工具
4. 控制与恢复(IV) ← 与业务风险同步推进
5. 评测集(贯穿) ← 尽早建立,可以很小
6. 上下文工程(I) ← 缓存排序与表示形式先做(便宜、立竿见影)
容量类优化等规模成为瓶颈再说
```
### 过度设计的自查信号
- 在为一个**从未被测量过**的问题写防御
- 一个措施只能靠 P4 ③ 级实现,而你还没回头看上游的工具设计
- 同一个问题上叠了两层以上防御
- 提示词规则已超二十条,还在加(P6)
- 为「将来可能」建的抽象,当下只有一个实现
---
## 十二、人 · Agent · 程序
本章把前面的原则放回三方协作的全景里。**Agent 不是一个独立系统,它是三方分工中的一层。**
### 三方的比较优势
| | 擅长 | 不可靠之处 | 成本结构 |
|---|---|---|---|
| **程序** | 确定性、可重复、可测试、快、可审计 | 只能处理**预想到**的情况 | 一次性开发 |
| **模型** | 理解模糊输入、编排、消歧、解释 | 不稳定、会编造、算不准、无法审计 | 每次调用都付费 |
| **人** | 价值判断、承担责任、掌握系统外的信息 | 慢、贵、会疲劳、注意力有限 | **最稀缺的资源** |
### 一次完整任务的流转
```
人 Agent(模型) 程序(确定性)
───────── ───────────── ──────────────
│ │ │
① │ 意图(自然语言) │ │
├─────────────────────────>│ │
│ │ ② 理解 → 结构化意图 │
│ ├─────────────────────────>│
│ │ │ ③ 查询 · 计算
│ │ │ 校验 · 排程
│ │<─────────────────────────┤
│ │ ④ 摘要 + 指针(不含明细) │
│ │ │
│ │ ⑤ 够了吗?不够 → 回 ② │
│ │ │
⑥ │ 明细 · 表格 · diff(绕过模型,直接渲染) │
│<────────────────────────────────────────────────────┤
│ │ │
⑦ │ 解释(只说为什么, <────┤ │
│ 不复述数字) │ │
│ │ │
⑧ │ 决策:确认 / 修改 / 否决 │
├────────────────────────────────────────────────────>│
│ │ │ ⑨ 落库
│ │ │ (人点了才发生)
│<────────────────────────────────────────────────────┤
⑩ │ 回执 · 可撤销 · trace │
```
**四个关键点**
| | |
|---|---|
| **⑥ 明细绕过模型** | 大载荷从程序直达人。这是 P2 在架构上的样子——不是「叮嘱模型别复述」,是**让它没有可复述的东西**。⚠️ 小数据不必绕(见 §2 量级判据),绕了反而牺牲追问能力 |
| **⑦ 给事实,不给成品句子** | 服务端给结构化事实,措辞由模型组织。成品句子有固定形状,模型学会形状就能**凭空背出来**且看不出真假;而且「照抄」浪费了模型唯一不可替代的能力。写作规范集中写一处,⛔ 别散在每个工具的说明里 |
| **⑧⑨ 落库由人触发** | 不是模型触发。模型至多把提案推到「待确认」 |
| **②⑦ 是模型仅有的两处贡献** | 入口的理解、出口的解释。**中间它是编排者,不是生产者** |
| **⑤ 是自主性所在** | 够不够、要不要再查一轮、要不要反问——这一步不该被流程编排替代(§0) |
### 分工判据
```
能写成确定性规则吗?
┌───────────┴───────────┐
能 不能
│ │
【 程序 】 错了要紧 / 不可撤吗?
(即便模型也会做, │
仍然交给程序) ┌─────────┴─────────┐
否 是
│ │
【 模型 】 模型准备 → 【 人 】
(压缩到可判断的规模)
独立于以上的一条:需要有人担责吗? → 只能是【 人 】
```
**第一问的常见错误**:因为「模型也能做」就交给模型。判据是**能不能写成规则**,不是**谁能做**
### 三方互为对方兜底
```
┌───────────────┐
│ 人 │ 判断 · 担责 · 场外信息
└───┬───────┬───┘
│ │
┌─────────┘ └─────────┐
│ │
┌──────┴───────┐ ┌───────┴──────┐
│ 模型 │◄────────►│ 程序 │
│ 理解 · 编排 │ │ 执行 · 校验 │
└──────────────┘ └──────────────┘
```
| 从 → 到 | 提供什么 |
|---|---|
| 人 → 模型 | 意图、纠偏、终审 |
| 模型 → 人 | **把万级压缩到可判断的十级**、解释、拿不准时反问、**引导下一步**(见下) |
| 模型 → 程序 | 结构化意图、调用参数 |
| 程序 → 模型 | 事实、验证手段、可据以纠正的错误消息 |
| 人 → 程序 | 确认、配置规则 |
| 程序 → 人 | 明细直渲、diff、回执、可撤销 |
**各自的失败被谁接住**
| 谁的失败 | 表现 | 谁接住 |
|---|---|---|
| 程序 | 遇到没预想到的情况 → 报错或僵住 | **模型**(它恰好擅长未预见输入) |
| 模型 | 编造、算错、漏步骤 | **程序**(不给它算的机会 + 校验)**+ 人**(终审) |
| 人 | 疲劳、批量盲点确认、注意力耗尽 | **程序**(默认值安全、diff 高亮变化、可撤销)**+ 模型**(把规模压到可判断) |
### 引导:模型 → 人 这条边上最容易做错的一项
**为什么必须有**:工具清单只有模型看得见,而人不读文档——**引导是能力披露的主要通道**。没有引导,等于让人自己猜系统能做什么。
**为什么必须设计而非放任**
| 风险 | 表现 |
|---|---|
| **引导是权力** | 被建议的选项会成为默认路径,多数人顺着走 |
| **可能编造不存在的能力** | 「要我导出 PDF 吗」——但并无此工具。幻觉出现在引导位上尤其有害:人会答应,然后失败 |
| **仪式化** | 不论有无实质下一步都追加一句 → 噪音,且钝化人对真正需要决策处的敏感 |
| **责任模糊** | 「是它建议我这么做的」——回到责任不可委托 |
**分工:候选由程序给,措辞由模型给,决定权在人。**
```
程序 ──→ nextActions[] 基于当前状态确定性计算
只含真实存在的动作
模型 ──→ 从中挑选最相关的 1–2 条,用人话说出来
人 ──→ 选,或不选
```
三个副产品:**不会编造能力**(候选来自程序);**不会仪式化**(程序知道当前状态是否真有下一步,没有就不给);**责任仍在人**(引导只是选项)。
这也是 §5「工具返回值是第 2 优先容器」最典型的用法。
**引导 ≠ 反问**:反问的触发条件是模型**不确定**(应调 clarify);引导的触发条件是模型**确定**,但人有选择余地。同一条边,触发条件相反。
**强度分级,越强越该收归程序:**
| 强度 | 形态 | 归谁 |
|---|---|---|
| 弱 | 陈述可能性(「还可以按医生筛」) | 模型可自由行使 |
| 中 | 建议(「建议先核对 X」) | 模型行使,但需真实依据 |
| 强 | **预选默认值** | **程序的职责**,且必须是安全侧默认——这已经是在替人做决定 |
#### 强引导:把引导节点做成数据
当流程有明确的决策点时,比起让模型每轮临场判断该提什么,更好的做法是**把节点固化成确定性数据**,随结果一起下发:
```ts
{ key, severity,
title, // 一句话说清是什么
why, // 一句事实
default: { label, isNoop: true }, // 不动会怎样
options: [{ label, intent, args }] } // 能做什么
```
五条规则:
1. **只陈述事实,不给建议。** `why` 是「他们的专属客服已排满」,不是「建议你铺平」。选项中性并列——给建议就是替人做决定。
2. **默认永远是 no-op。** 这样「一条不点、直接完成」在任何情况下都是安全的。
3. **有后果的节点必须全亮,⛔ 不许截断。** 藏起一条,用户就不知道有东西卡着。
防噪音靠**分层**不靠丢弃:**需处置**(不管就有后果)全部展开并按 `severity` 排序;**提示**(纯信息)默认折叠。排序规则必须确定性。
4. **每次重算,不做增量。** 增量会漂,而漂了不报错。
5. **不是关卡。** 不阻塞用户直接完成,自由通道始终开着——所以它不违 B1。
6. **选项做成可直接点击执行的动作**,⛔ 不是让人照着打字;自由输入只作兜底。
打字 → 模型理解 → 翻译成动作,这条链每一环都可能出错;点击是**确定性**的,动作直接给出,没有理解环节。
**三方各取所需**:界面渲染成可点的行;模型只拿 `title / why / defaultLabel` 用来组织语言(⛔ 不给 options 的技术细节);人来决定。
**两个入口必须收敛到同一个 `intent` 契约**——界面点击与「用户直接说」走同一条路,这样「界面能做的模型也能做」是结构保证,不是提示词里的一句叮嘱。
> 附带收益:节点数组天然就是那段话的**结构骨架**(未处置的节点 = 「要你定的」那一块)。
> 靠提示词要求模型分小节是 P4 ③ 级;由数据决定结构是 ① 级。
#### 「下一步做什么」应当是确定性可答的
```
下一步 = 当前状态 + 未处置的节点 + 该状态的默认路径
```
用户随时问「接下来呢」,答案由状态机推出,⛔ 不由模型即兴发挥。
两个后果都很值钱:**永远不会指向系统做不到的事**(否则就是 G2 说的编造能力);**永远不会漏掉卡住的东西**
### 责任不可委托
模型不能被追究、不能被解雇、不能被起诉,**因此它不能承担责任**
推论:**写屏障的存在理由不是「模型可能出错」,而是「必须有人负责」。** 这是 P5 里典型的「业务本身要求」——模型再强也不该拆除。
同理:「这是 AI 决定的」不是一个可用的解释。任何对外生效的动作,都必须能追溯到一个具体的人的确认。
### 把人放在他真正能判断的位置
**让人审 400 条 = 没有人在审。** 这不是人的问题,是分工设计的问题。
人的注意力是三方中最稀缺的资源,必须花在**只有人能做的判断**上:
| 不该给人的 | 该给人的 |
|---|---|
| 逐条核对明细 | 这一批该不该做 |
| 手写筛选条件 | 这个口径对不对 |
| 记住上下文 | 这个例外要不要放行 |
对应到工程:**每一步都要收敛规模**,让最终落到人面前的判断数量级降到十位以内。
### 三方比例会变,结构不变
模型变强,**改变的是「模型 ↔ 人」的边界**——更多原本需要人判断的事可以交给模型。
**但它不改变「模型 ↔ 程序」的边界**,因为那条线的依据是「能不能写成规则」与「确定性 / 成本」,与模型强弱无关:再强的模型也不该用来做计数和排序。
**也不改变「人」在责任层的位置**,那条线的依据是问责,不是能力。
### 反模式
| 反模式 | 后果 |
|---|---|
| 让模型做程序的活(算数、排序、去重、分页) | 慢、贵、不可靠,且不可审计 |
| 让程序做模型的活(正则 / 关键词去理解自然语言意图) | 脆,一变就崩 |
| 让人做程序的活(人肉核对批量数据) | 疲劳性错误,且规模一大就形同虚设 |
| 让人做模型的活(人肉拼筛选条件) | 慢,而且这正是 agent 该消化掉的部分 |
| 让模型担责 | 无法追究,且是合规风险 |
| 引导完全交给模型自由发挥 | 会编造不存在的能力;会退化成每轮一句的仪式 |
| 把人放在错误的规模上 | 表面有人把关,实际无人把关 |
---
## 附:本文的边界
- 本文描述的是**架构层面**的判断标准,不含任何具体实现。
- 对流行术语的判定是**依场景而定的结论**,不是普适裁决;场景变化(如开放能力、引入非结构化数据、agent 承担更多角色)时应重新评估。
- LLM 能力演进很快,其中标注为「弥补模型不足」(P5)的措施,应当定期复查是否仍有必要。
# Agent 设计教条
> 给产品与业务读。**一条一句,不解释。**
> 完整论证与图示见 [agent-architecture.md](./agent-architecture.md)。
| | |
|---|---|
| **性质** | 通用设计原则,与具体业务无关 |
| **用法** | 评审时逐条对照;争议时引用编号 |
| **评级** | ★★★ 地基(不这样做必出问题,事后修改等于重做)· ★★ 必做(会出问题,但可后补)· ★ 看信号(有明确信号再做,提前做是浪费) |
---
## 只记五条
1. **能写成规则的交程序,写不出规则的才交模型。**
2. **模型报的每个数字都要指得到来源。**
3. **不可逆的动作必须有人点头——因为必须有人负责。**
4. **让人审四百条,等于没人在审。**
5. **提示词是最弱的手段,永远最后用。**
---
## A · 人、模型、程序的分工
| 级 | | 教条 |
|---|---|---|
| ★★★ | **A1** | 能写成规则的交程序,写不出规则的才交模型。判据是「能不能写成规则」,不是「谁能做」。 |
| ★★★ | **A2** | 模型报出的每个数字,都必须能指到**本轮**某次工具返回。⛔ 不许从上文抄、不许估、不许自己算派生量(百分比、差值、交叉人数)。<br>⚠️ 判据是「**数字要有锚**」,不是「不给数字」——模型的算力在变强,但「引用了没有来源的数」和「跨轮串用旧条件下的数」跟算力无关。 |
| ★★★ | **A3** | 不可逆的动作必须由人确认。理由不是模型会错,是必须有人负责。 |
| ★★★ | **A4** | 责任不可委托。「AI 决定的」不是一个解释。 |
| ★★ | **A5** | 明细直接呈现给人,不经过模型。 |
| ★★ | **A6** | 让人审四百条等于没人在审。每一步都要收敛规模,落到人面前的判断压到十位以内。 |
| ★★ | **A7** | 人的注意力是三方中最稀缺的资源,只花在只有人能做的判断上。 |
| ★ | **A8** | 模型变强只移动「模型与人」的分界,不移动「模型与程序」的分界。 |
## B · 模型的自主
| 级 | | 教条 |
|---|---|---|
| ★★★ | **B1** | 主线是给能力,不是加限制。不要用固定流程替换模型的判断。 |
| ★★ | **B2** | 「我不知道」必须是一个合法答案。不确定就停下来问,不要猜。 |
| ★★ | **B3** | 给模型验证手段,比给它规则有效。 |
| ★ | **B4** | 不要按阶段藏工具——走错一步之后它就无法自己纠回来。 |
## C · 可靠性
| 级 | | 教条 |
|---|---|---|
| ★★★ | **C1** | 防错分三级:让错误不可能 ≫ 让错误可见可撤 ≫ 事后校验。只能靠第三级实现的,多半是上游设计有问题。 |
| ★★★ | **C2** | 全程留痕、可回放。没有它,之后所有排查都是盲的。 |
| ★★★ | **C3** | 每条防错措施都要标明是「弥补模型」还是「业务要求」。前者随模型变强要拆掉,后者永远保留。 |
| ★★ | **C4** | 提示词是最弱的手段,永远最后用;而且规则越多,每条被遵守的概率越低。 |
| ★★ | **C5** | 没有回归测试,质量就是随机游走。判定看它做了哪些动作,不看它说了什么话。 |
| ★★ | **C6** | 批量动作必须可整体撤销。 |
## D · 身份与权限
| 级 | | 教条 |
|---|---|---|
| ★★★ | **D1** | 身份由登录决定,不由模型或提示词决定。提示词从来不是安全边界。 |
| ★★★ | **D2** | 权限不同的角色不能共用一个会话。上下文是单向的,进去的数据不会因为一句话就消失。 |
| ★★★ | **D3** | 模型不传就不会越权的参数,不要让它传。 |
| ★★ | **D4** | 角色不同,能看到的工具也要不同。看不见比看见了被拒更安全。 |
| ★ | **D5** | 同一套实现按身份参数化即可,不必做成两个 agent。 |
## E · 给模型看什么
| 级 | | 教条 |
|---|---|---|
| ★★★ | **E1** | **按量级决定给原文还是给摘要**:几十条给原文(摘要是有损的,损失的往往正是后面要追问的那部分);几百条以上只给数量 + 入口;**量级未知时先取数量,再决定取不取**。 |
| ★★★ | **E2** | 给了摘要就必须给「怎么取到明细」,两者缺一不可。 |
| ★★ | **E9** | 多轮对话必须带上完整会话脉络,**但要分层带**:工具调用带「名字+关键参数+结果摘要」,大载荷只带 id + 一句话,失败只带错因。 |
| ★★ | **E3** | 任务状态存在库里,不存在对话里。对话只是当轮的工作台。 |
| ★★ | **E4** | 脏数据在源头就别产生,而不是事后清理。 |
| ★★ | **E5** | 对话历史只追加,不修改、不重排——这直接决定响应速度和成本。 |
| ★★ | **E6** | 固定不变的内容放前面,每次都变的放后面。当前时间这类东西绝不能写进固定部分。 |
| ★ | **E7** | 同一份数据换个呈现形式,模型的准确度会变;这件事几乎零成本,值得试。 |
| ★ | **E8** | 材料太多时,派一个只读助手去看,只把结论带回来。 |
## F · 工具
| 级 | | 教条 |
|---|---|---|
| ★★★ | **F1** | 工具的粒度等于用户会说的一句话。 |
| ★★★ | **F2** | 每个选项列表都要给全,并永远留一个「不确定」。缺一个值,模型必然填错,而且不会报错。 |
| ★★ | **F3** | 规则写在工具说明里,比写在提示词里管用。 |
| ★★ | **F4** | 错误提示要写成模型能据此改正的话。 |
| ★★ | **F5** | 每加一个会改数据的工具,先回答「它怎么知道自己做对了」。 |
| ★★ | **F6** | 写操作必须可重复执行而结果不变——模型会重试。 |
| ★★ | **F7** | 界面要的数据和模型要的数据分开返回。 |
| ★★ | **F8** | **高频做专用,长尾留一个受控的兜底工具。** 专用工具的名字就是意图,选错的空间小;没有兜底则未预见的需求只能回「做不到」。兜底的参数必须是枚举化的维度,⛔ 不能是自由查询语句。 |
| ★★ | **F9** | 每个列表类工具都要有「只回数量」的轻量对应物 —— 先问有多少,再决定取不取。 |
| ★ | **F10** | 只在特定场景才需要的解读规范,随该工具的**返回值**下发(`_guide`),⛔ 别常驻在工具说明或提示词里。这是按需加载最便宜的形态:零往返、必然到达。 |
## G · 表达与引导
| 级 | | 教条 |
|---|---|---|
| ★★ | **G1** | 可选的下一步由程序算出,模型只负责挑选和措辞。 |
| ★★ | **G2** | 模型不得建议系统做不到的事。 |
| ★★ | **G3** | 没有实质下一步时,不要凑一句建议。 |
| ★★ | **G4** | 预设默认值是在替人做决定,归程序管,且必须偏安全一侧 —— **默认永远是「不动」**。 |
| ★★ | **G5** | **布局可以交给模型,数据不行。** 模型写结构 / 样式 / 图表类型和占位符,真值在渲染时注入。⛔ 别让它把数据逐字抄进产物。 |
| ★★★ | **G6** | 给模型**结构化事实**,不要给成品句子;措辞由它组织,写作规范集中写一处。<br>⚠️ 成品句子有固定形状,模型学会形状就能凭空背出来(而且看不出真假);结构化事实编不出来。⛔ 「照抄」也浪费了模型唯一不可替代的能力。 |
| ★★ | **G7** | 强引导的节点由程序确定性判定,⛔ 不由模型判断哪条该提。 |
| ★★ | **G8** | 引导节点**只陈述事实,不给建议**;选项中性并列;不阻塞用户直接完成。 |
| ★★ | **G9** | **有实际后果的节点必须全亮,⛔ 不许截断。** 防噪音靠**分层**(需处置的展开、纯信息的折叠),不靠丢弃 —— 藏起一条,用户就不知道有东西卡着。 |
| ★★ | **G10** | 引导选项做成**可直接点击执行的动作**,⛔ 不是让人照着打字;自由输入只作兜底。<br>⚠️ 打字 → 模型理解 → 翻译成动作,这条链每一环都可能错;点击是确定性的。两条路必须收敛到**同一个动作契约**。 |
| ★★ | **G11** | 「下一步做什么」必须是**确定性可答**的:当前状态 + 未处置的节点 + 默认路径,⛔ 不由模型即兴发挥。由此推出的下一步永远不会指向系统做不到的事。 |
## H · 要不要上
| 级 | | 教条 |
|---|---|---|
| ★★★ | **H1** | 任何手段进门前三问:解决什么问题、这个问题现在有多严重(有数据吗)、有没有更便宜的解法。答不出第二问就是在赶时髦。 |
| ★★ | **H2** | 清单是用来过一遍的,不是用来全打勾的。不做可以,但要知道自己没做。 |
| ★★ | **H3** | 为从未被测量过的问题写防御,就是过度设计。 |
| ★ | **H4** | 结构化的数据不要用语义检索——查询语言本身就是检索。 |
| ★ | **H5** | 「记不下」通常说明缺一张表。先建表,别做记忆。 |
| ★ | **H6** | 不要让模型自己积累经验并固化成规则——没法调试,出问题查不出是哪一条学坏的。 |
| ★ | **H7** | 只在出现第二个入口、或已列入能力开放计划时,才把工具做成通用封装。 |
---
## 一页速查
| 主题 | ★★★ |
|---|---|
| **分工** | A1 规则归程序 · A2 数字要有锚 · A3 不可逆动作过人 · A4 责任不可委托 |
| **自主** | B1 给能力而非加限制 |
| **可靠** | C1 防错三级 · C2 全程留痕 · C3 标明措施归属 |
| **权限** | D1 身份由登录定 · D2 角色不共用会话 · D3 越权参数不给模型 |
| **输入** | E1 按量级给原文/摘要 · E2 摘要配指针 |
| **工具** | F1 粒度=一句话 · F2 选项给全留兜底 |
| **表达** | G6 给事实不给成品句子 |
| **取舍** | H1 进门三问 |
# 分配 Agent · 改造规划
> 把现有助手改造到 [assignment-agent-flow.md](./assignment-agent-flow.md) 定义的行为,
> 并按 [agent-doctrine.md](./agent-doctrine.md) 的教条整理代码组织。
| | |
|---|---|
| **状态** | 待开工 |
| **起点** | 现有实现 —— 服务端 5 文件约 1600 行,前端 3 文件约 3100 行 |
| **原则** | 能用的保留;乱的整理;错的重写。⛔ 不为了整齐而重写已经正确的东西 |
---
## 一、不动的部分
这几处已符合教条,**重写等于倒退**
| 保留 | 依据 |
|---|---|
| `mcp-auth.service.ts` 整条鉴权链路 | D1–D3 全中,无服务账号,身份随每次调用传递 |
| MCP 工具的 scope 下推 + `resolveClinicId` | D3 |
| 侧信道机制本身 | E1 |
| 条件注册 + 能力分桶缓存 | D4 |
| `assertRevokeActuallyHappened` 前端对账 | C2 在线埋点 |
| `streamText` 自主循环(含 8 步上限) | B1 |
| 代码里那些带日期的踩坑注释 | 它们是**证据**,⛔ 不许在重构中丢掉 |
---
## 二、分阶段
### P0 · 独立小改(不依赖架构改动,可立刻做)
| # | 改什么 | 落点 | 收益 |
|---|---|---|---|
| 1 | 工具返回值里的取值码 → 中文 | `mcp-server.factory.ts` + `plan.service` 返回映射 | **直接删掉提示词里两页「不许说出取值码」** |
| 2 | 长描述 → 返回值 `_guide` | `get_assignment_detail`(35 行)、`list_assignment_batches` | F10,工具清单瘦身,常驻成本归零 |
| 3 | `stopWhen(8)` 到顶时 log + 告知前端 | `assistant.service.ts` | 现在是静默截断 |
| 4 | `SYSTEM_PROMPT` 改掉「为牙科诊所的客服人员提供帮助」 | `assistant.service.ts` | 主管也在用,与 `DISPATCHER_EXTRA` 打架 |
### P1 · 引导节点(核心新增)
| # | 改什么 | 落点 |
|---|---|---|
| 5 | `Signal` 类型 + 6 个判定器 + 需处置/提示分层 | **新建** `plan/assignment-signals.ts` |
| 6 | **基数改造**:每人每日 15 × 在岗 × D,⛔ 不再沿用上次 | `assignment-proposal.service.ts` |
| 7 | `AssignmentProposal``signals[]` | `packages/types` |
| 8 | 画像亮点基线(同治疗项池子的分布) | 复用 `cohort-attributes.service` |
| 9 | **intent 契约**:节点选项 → intent;`edit_assignment_sheet` 拆成意图级工具,与按钮共用同一组 intent | **新建** `plan/assignment-intents.ts` |
| 10 | 确认单卡片渲染节点区 + 选项按钮 | `assignment-confirm-sheet.tsx` |
> ⚠️ 第 10 项有现实障碍:该文件已 **1540 行**,加节点区之前必须先按 §三 拆分。
### P2 · 事实与措辞分离
| # | 改什么 |
|---|---|
| 11 | `selectionNote` / `basisNote` / `pendingNote` 成品句子 → **结构化事实对象**(两段) |
| 12 | 删掉 `propose_assignment` 返回值里那 25 行指令 |
| 13 | 散落各工具描述里的措辞禁令 → **一处写作规范** |
| 14 | 删掉 `###` 骨架规则 —— 结构由 `signals` 决定,不再靠模型自觉 |
**这一步一次性消掉三类问题的根**:背成品句子、复述卡片数字、改写措辞。
### P3 · 上下文与历史
| # | 改什么 |
|---|---|
| 15 | `toApiMessage` **分层回传**工具调用(名字 + 关键参数 + 结果摘要) |
| 16 | 小数据给原文:候选 ≤50 人时明细进上下文,⛔ 不必一律走侧信道 |
> ⚠️ 15 是本轮唯一会显著改变模型行为的改动,建议单独上、单独观察。
### P4 · 草稿状态机(可与 P1 并行)
| # | 改什么 |
|---|---|
| 17 | `active / superseded / confirmed / cancelled`**新版生成即作废旧版** |
| 18 | 作废卡片显示「已作废」+ 确认按钮禁用(⛔ 不许静默变灰、⛔ 不许消失) |
| 19 | edit 作用于唯一 `active`,删掉「倒着扫消息找最后一张」 |
### P5 · 工具补齐
| # | 改什么 | 依据 |
|---|---|---|
| 20 | `explain(planId)` —— 「为什么这个人给了张悦」 | F5,现在模型只能编 |
| 21 | `validate(draft)` + read-back | F5 |
| 22 | 兜底 `query_cohort(枚举化 filters)` | F8 |
| 23 | 各 list 工具补 count 模式 | F9 |
| 24 | 测 `get_persona`/`get_facts`/`get_recall_plan` 使用率 → 可能折成 `overview(sections)` | 同质化 |
### P6 · 减法与评测(**必须最后**)
| # | 改什么 |
|---|---|
| 25 | 提示词减法 —— `DISPATCHER_EXTRA` 231 行大幅缩减 |
| 26 | golden set:断言工具调用序列 + 节点该命中没命中 |
> ⚠️ 25 放最后的原因:前面每做一步就有一批规则被结构消化掉。
> **先删提示词等于删掉还在起作用的护栏。**
### 依赖关系
```
P0 ──────────────────────────────▶ 完全独立,随时做
P4 ──────────────────────────────▶ 独立,可与 P1 并行
P1 ──▶ P2 ──▶ P6
P3 ──────────────────────────────▶ 独立,单独观察
P5 ──────────────────────────────▶ 独立,按需加
```
**建议起步**:P0 + P4(都独立、都不碰模型行为、都能立刻验证),然后 P1 → P2 连着做。
**`render_artifact` 整个后置** —— 实验性质,不阻塞主线。
---
## 三、代码组织
### 现状问题
| | |
|---|---|
| **职责混杂** | `assistant.service.ts` 457 行里混了三件事:MCP 工具适配、本地工具定义、streamText 循环。其中约一半是内联的工具描述字符串 |
| **描述内联拼接** | `tool({ description: '...' + '...' + ... })` 几十行拼接,改一个字要在里面找 |
| **同类内容三处散落** | 成品句子在 proposal service、解读规范在 tool description、措辞禁令在 prompt |
| **前端超大文件** | `assignment-confirm-sheet.tsx` 1540 行、`assistant-chat.tsx` 1007 行 |
| **类型分散** | `Block` 在 hook 里、`AssignmentProposal`/`SheetEditOp` 在 types |
### 目标结构 · 服务端
```
modules/assistant/
assistant.service.ts 只剩:装配工具 + 跑循环
assistant.controller.ts 只剩:SSE 转发
tools/
index.ts 工具注册表(唯一清单)
propose-assignment.tool.ts
draft-intents.tool.ts P1 拆分后的意图级工具
render-artifact.tool.ts
prompts/
system.ts 通用人设
writing-style.ts ⭐ P2 新增:唯一的写作规范
dispatcher.ts 角色约束(P6 后大幅缩减)
staff.ts
modules/mcp/
model-facing.ts ✅ P0 已落:取值码 / 桶名 → 中文(只翻译,不判断)
guides.ts ✅ P0 已落:随返回值下发的解读规范(`_guide`)
⚠️ 放这里而不是 assistant/ —— 与发出它们的工具同址
modules/plan/
assignment-proposal.service.ts 只出提案
assignment-signals.ts ⭐ P1:引导节点判定
assignment-facts.ts ⭐ P2:两段结构化事实
assignment-intents.ts ⭐ P1:intent 契约与执行
```
### 目标结构 · 前端
```
components/assistant/
assistant-widget.tsx
assistant-chat.tsx 只剩消息流编排
chat/
markdown.tsx 从 assistant-chat 拆出
tool-step.tsx
artifact.tsx
confirm-sheet/
index.tsx 从 1540 行拆
signals.tsx ⭐ P1:引导节点区
summary-bar.tsx
agent-group.tsx
patient-row.tsx
```
### 规范
1. **一个工具一个文件**,导出 `{ name, description, inputSchema, execute }`;描述用顶部常量,⛔ 不内联拼接。
2. **工具描述里只写「怎么用这个工具」**;「怎么解读结果」走 `_guide`(F10)。
3. **⛔ 工具返回值里不许写给模型的指令。** 已经栽过一次:`get_assignment_detail``note` 字段混着内部指令,模型照抄贴给了主管。P2 之后返回值只有事实。
4. **跨端共享的形状一律定义在 `packages/types`**;前端只保留纯 UI 概念(如 `Block`)。
5. **文件行数软上限**:服务端 300 行 / 前端组件 250 行。超了先问「是不是混了两件事」,再拆。
6. **踩坑注释保留并统一格式**
```ts
// 🔴 2026-08-06 实测:<现象> → <结论>
```
带日期、现象、结论三要素。**这些是证据不是啰嗦**,重构时随代码迁移,⛔ 不许顺手删。
7. **每条护栏标注归属**(C3):`// [弥补模型]``// [业务要求]`。前者随模型变强要复查,后者永久保留。
### 术语表(代码与文档统一)
| 中文 | 代码标识 | 说明 |
|---|---|---|
| 引导节点 | `Signal` | ⛔ 不叫「灯」 |
| 确认单 | `sheet` | UI 侧的叫法 |
| 草稿 | `draft` + `state` | 数据侧的叫法,含 `active/superseded/confirmed/cancelled` |
| 动作契约 | `AssignmentIntent` | ⭐ 按钮与模型工具**共用同一个** |
| 事实两段 | `facts.selection` / `facts.dispatch` | 怎么选的 / 怎么派的 |
| 待分配 | `pending` | |
| 解读规范 | `_guide` | 随工具返回值下发 |
---
## 四、验收
每个阶段完成时至少要能回答:
| | |
|---|---|
| **P0** | 提示词里「不许说出取值码」那两页删干净了吗? |
| **P1** | 每个引导节点都能构造出触发用例吗?默认全部是 no-op 吗? |
| **P2** | `propose_assignment` 的返回值里还有指令性文字吗?(应为零) |
| **P3** | 模型能在第 2 轮引用第 1 轮的工具结果吗?上下文有没有变胖? |
| **P4** | 出第二版后,第一版卡片显示「已作废」且按钮禁用了吗? |
| **P5** | 每个写工具都能回答「模型怎么知道它做对了」吗? |
| **P6** | golden set 跑得起来吗?删提示词前后它的通过率变了吗? |
# 分配 · Agent 行为决策树
> 主管发起一批召回分配时,**人 / 模型 / 程序**各自在哪一步做什么。
> 通用原则见 [agent-doctrine.md](./agent-doctrine.md)(引用其编号);业务教条见 [plan-assignment-doctrine.md](./plan-assignment-doctrine.md)(引用 T 编号)。
| | |
|---|---|
| **状态** | 定稿(Locked) |
| **性质** | Agent 行为规格 —— 实现按此对齐 |
| **⚠️ 立场** | **这是状态图,不是流程脚本。** 描述「有哪些状态、哪些是确定性的」,⛔ 不描述「模型必须按什么顺序调工具」。⛔ 不得据此做工具门禁(B1 / C4) |
---
## 一、状态机
**主管只在两个地方停下来**:看草稿、看已确认的批次。其余全是转移。
```
┌──────────────┐
│ 无草稿 │ 起点
└──────┬───────┘
│ [人] 圈定:矩阵点格 / 中间表 / 直接说
│ [程] 一次算完(见 §二)→ 生成新草稿
│ ⭐ 新草稿一生成,前一版立即作废
┌────────────▶ ┌──────────────┐
│ │ 草稿 active │ ← 系统在等主管
│ └──┬──┬──┬──┬──┘
│ │ │ │ │
│ 改派法 ───────┘ │ │ └──── 放弃 ──▶ 无草稿
│ 同一版重算节点 │ │
│ │ └─────── 确认 ──▶ ┌──────────────┐
│ 改人群 ──────────┘ │ 已确认 │
└─── 出新版,旧版 [superseded] └──────┬───────┘
补福利 / 撤销 / 跟踪
(主管在等客服)
```
### 三个状态
| 状态 | 谁在等谁 | 能做什么 |
|---|---|---|
| **无草稿** | 系统等主管 | 圈定 |
| **草稿 `active`** | 系统等主管 | 处置引导节点 · 改派法 · 改人群 · 确认 · 放弃 |
| **已确认** | 主管等客服 | 补福利 · 撤销(限时)· 跟踪 |
### ⚠️ 「圈定 → 排人 → 算引导节点」是**一次转移**,不是三个状态
服务端一次算完、一次往返。把它拆成三步画会让人以为中间有停顿——**没有**
`total == 0` 也不是独立状态:草稿照样生成,只是它是空的,唯一能做的是改人群。)
### 入口的三种来源
| | | 模型要做什么 |
|---|---|---|
| A | 矩阵点一格 | **什么都不用做**,参数确定 |
| B | 中间表确定 | 同上 |
| C | 对话里直接说 | 理解成 治疗项 · 时间档 · 口径;拿不准就反问,⛔ 不猜 |
⚠️ A/B 是主路径。**大多数情况下模型在入口这一步是零参与的**
---
## 二、圈定这一次转移里,服务端算了什么
按顺序,全部确定性,一次返回:
```
1. 候选集 口径(治疗项 · 时间档 · 锚点)→ total · 可切维度
排序:没被分过的优先
2. 基数 每人每日 15 条 × 在岗人数 × D → N(与候选总数取小)
3. 落人 专属优先(封顶在目标水位)
→ 无主补空(给手上最少的)
二者共用水位法(= 负载均衡)
落不下去的 → 待分配残留
4. 引导节点 基于 1–3 的结果做确定性判定(§五)
待分配残留 = 第一个节点
5. 两段事实 怎么选的(选谁)/ 怎么派的(给谁)(§四)
```
**产出一个草稿对象**:明细 + 两段事实 + 引导节点。前端直渲,模型只拿事实和节点的摘要。
---
## 三、草稿 active 状态下的四条出路
```
草稿 active
├── 不动 ─────────────────────────────▶ 确认
│ 每个节点默认都是 no-op,所以这条路**永远安全**
├── 点节点上的选项 ── [程] 执行 intent ──▶ 同一版,重算节点
├── 说节点外的话 ──── [模] 判断动的是哪一层
│ │
│ ├─ 动「怎么派」 → [程] 局部改 ────▶ 同一版,重算节点
│ │ 谁给谁 · 时效 · 移出本批 · 福利
│ │
│ └─ 动「这批人」 → [程] 重跑 ──────▶ **出新版,旧版当场作废**
│ 换口径 · 加画像条件 · 改人数 · 换治疗项
└── 放弃 ──────────────────────────────▶ 无草稿
```
⚠️ 前三条都回到 `active`,只是**回到哪一版**不同:改派法回同一版,改人群回新版。
### 「派法」还是「人群」—— 模型在本流程中最实质的一次判断
| | 动的是 | 走 | 例 |
|---|---|---|---|
| **派法** | 这批人**怎么分** | 局部改,不重跑 | 「王强移出这批」「待分配各自归专属」「给 5 天」「带个福利」 |
| **人群** | 这批人**是谁** | 重跑,出新版 | 「只要商保直付的」「改成 200 人」「换成 1–2 年那档」「按末诊算」 |
**代价不对称**:把「人群」误判成「派法」→ 主管以为条件生效了、其实没有(**静默错**);反过来只是多算一次。
**拿不准时一律走重跑。**
### ⛔ 「怎么改这张单」不由模型讲
改单的操作方式(拖拽、点选、可改哪几项)写在**卡片的操作说明**里常驻。
模型只在主管开口之后**翻译成 intent**,⛔ 不主动教学、⛔ 不每轮复述(G3)。
---
## 四、事实两段 + 引导节点
草稿的可读产出只有两类:**已经确定的事实**(两段)和**还没确定的引导节点**(§五)。
> ⚠️ **不要写成「三段事实」** —— 第三段「还没定的」本来就是引导节点,列成事实等于把同一份数据说两遍。
> 同理,**落人不是四段**:专属优先与无主补空是两段,水位法是二者共用的**机制**,待分配是落不下去的**残留**(= `pending` 节点)。
服务端产出**结构化事实**(⛔ 不是成品句子,G6),模型据此组织语言。
**呈现顺序锁定为:怎么选的 → 怎么派的 → 引导节点**(即「**已排好的在前,要他定的在后**」,2026-08-06 产品定)。
理由:主管要先知道**这批本身是什么样**,再看还剩什么要他处理。
⚠️ `assistant-prompts.ts` 第 0.5 条里那句「要他动手的那块永远排第一」与此冲突,**以本文为准**,实现时一并改掉。
### 事实① 怎么选的(**选谁**)
- 口径:治疗项 · 时间档 · 锚点模式(诊断 / 末诊)
- 排序:没被分过的优先
- 候选总数 → 取本批 N
| | |
|---|---|
| 每人每日 | **15 条**(当前默认;⛔ 暂不沿用上一次的值) |
| 时效 | `D` 天 |
| **本批目标 N** | **在岗人数 × 15 × D**,并与候选总数取小 |
### 事实② 怎么派的(**给谁**)
**两段落人**,按顺序:
| | 段 | 规则 |
|---|---|---|
| ① | **专属优先** | 有专属且在名册内 → 分给他,**封顶在目标水位** |
| ② | **无主补空** | 无专属 / 专属已离岗 → 给当前手上最少的人 |
**共用机制 · 水位法**:每一条都给当前手上最少的那个,⛔ 不是「每人加一样多」——起点不齐时那样终点还是不齐。
**派生**:每人本批多少条 · **折合每日多少条**(供 `daily_overload` 判定)。
> ⚠️ 「负载均衡」不是独立一段,就是这个水位法。它决定的是**给谁**,⛔ 不影响**选谁**。
> (目标水位封顶会把一部分专属患者挤进待分配、从而减少实际发出量——那是分配结果回头改变了发出量,候选集本身没变。)
### 落不下去的残留 → 引导节点
专属客服本轮已排满的那些人 **不分**,单列成 `pending` 引导节点交主管定
(T:关系层面的决定只有主管能做)。
⚠️ 有待分配就**必须说出来**,⛔ 不许省、不许弱化成「另有若干」。
⚠️ **但「还没定的」不只是待分配** —— 它是所有未处置的引导节点(另有每日超阈值、候选不足、画像亮点)。待分配只是 severity 最高的那一个。
---
## 五、引导节点表
引导节点的通用设计(数据形状、五条规则、三方各取所需)见 [agent-architecture.md §12](./agent-architecture.md)
### ⛔ 不截断 —— 命中的全部呈现
**凡是有实际后果的节点,一条都不能省。** 藏起任何一条,主管就不知道有东西卡着 —— 那正是待分配当初要解决的问题。
防噪音靠**分层**,不靠丢弃:
| 层 | 内容 | 呈现 |
|---|---|---|
| **需处置** | 不管就会有后果 | 全部展开,按 severity 排序 |
| **提示** | 纯信息,不管也没事 | 默认折叠成一行,可展开 |
### 确认前
| sev | 层 | key | 判定(程序) | 默认(no-op) | 选项(点击执行) |
|---|---|---|---|---|---|
| **1** | 需处置 | `pending` | `pending > 0` | 留着 = 这批不发给他们 | ① 各自归专属客服<br>② 铺平给在岗<br>**换无主患者补上**(见下)<br>④ 移出本批 |
| **2** | 需处置 | `daily_overload` | 某客服「本批条数 ÷ D」> 每日 15 条 | 不动 | 延长时效 / 减他这批的量 |
| **2** | 需处置 | `short_supply` | 候选数 < N | 全给 | 换口径 / 放宽一档 |
| **3** | 需处置 | `highlight` | 画像维度偏离基线 ≥ 2×,取 Top 2–3 | 不特殊处理 | 见下 |
| **4** | 提示 | `expiry_default` | D 是默认值 | 用默认 | 改天数 |
| **4** | 提示 | `anchor_nondefault` | 口径非默认 | 保持 | 换口径重出 |
**终止分支(不是引导节点)**`empty` —— `total == 0`,无法继续,只能放宽或换格。
#### `pending` 的第 ③ 个选项:换无主患者补上
待分配的人不发出去,这批**实发量就少了**。从池子里取**等量的无主患者**补进来:
既不动任何专属关系(T:不许替主管把患者从专属客服手里挪走),又把产能填满。
#### `highlight` 展开
判定统一为「本批占比 ÷ 基线 ≥ 2」;**基线取同治疗项的池子**,⛔ 不取全池
(种植的重要价值天然高于补牙,用全池会让种植永远命中)。
| 亮点 | 选项 |
|---|---|
| 重要价值 | 只做这些 / 优先给专属 |
| 转介绍 | 话术带上介绍人 |
| 权益身份 | 权益到期未用当由头 |
| 折扣锚点 | 福利定价参考上次折扣 |
| 获客渠道集中 | 统一话术口径 |
### 交互形态:点击为主,自由输入兜底
**每个选项都是一个可直接点击执行的动作**,⛔ 不是让主管照着打字。
```
┌──────────────────────────────────────────────────┐
│ 34 人的专属客服这轮排满了 │
│ 不处理 = 这批不发给他们 │
│ [各自归专属] [铺平] [换无主患者补上] [移出本批] │
└──────────────────────────────────────────────────┘
也可以直接告诉我要怎么做。 ← 兜底:一句文字说明
```
**为什么点击优先**:主管打字表达 → 模型理解 → 翻译成 intent,这条链每一环都可能错(实测栽过 `owner`/`balance` 选反,18 人被散给 17 位别人)。点击是**确定性**的,intent 直接给出,没有理解环节。
**为什么仍要留自由输入**:引导节点覆盖不了的需求必须有出口(B1 / F8),否则助手只能回「做不到」。
**两条路收敛到同一个 `intent` 契约** —— 这样「按钮能做的、说话也能做」是结构保证,⛔ 不是提示词里的一句叮嘱。
### 确认后
| key | 判定 | 默认 | 选项 |
|---|---|---|---|
| `no_benefit` | 已确认 且 `benefit == null` | 不带 | 带一个(填原文) |
⚠️ **福利引导刻意放在确认之后**:它只影响**此后生成**的话术,所以不设时限;
而客服从确认那一刻起陆续打开这批单,**每过一会儿能用上的人就少一个** —— 这是提醒的最佳时机。
**只提一次**,主管说不用就不再提。
**绝不许说「带福利成功率更高 / 转化率提升」** —— 本系统不统计成功,那个结论编不出来(A2)。
---
## 六、确认之后
```
已确认
├─ 福利引导 见上(唯一还能改的东西)
├─ 撤销说明 限时;⚠️ 客服**已经打开过**的单不会被收回
│ ⛔「已撤销」四个字只能出现在撤销工具真的返回之后(A2)
└─ 跟踪说明 → 另一条时间线,解读规范随工具返回值下发(F10)
```
**能改与不能改**
| | 确认后 |
|---|---|
| 福利 | ✅ 可补挂,不设时限 |
| 人员 | ❌ 单已在客服手上 → 走撤销重分 |
| 时效 | ❌ 同上 |
---
## 七、草稿的生命周期
```
出第 1 版 ──▶ [active]
出第 2 版 ──▶ 第 1 版立即 [superseded] ⭐ 作废发生在**新版生成时**
│ ⛔ 不是在确认时
出第 3 版 ──▶ 第 2 版立即 [superseded]
点确认 ──▶ 第 3 版 [confirmed] → 转成批次
```
**规则**
1. **任一时刻只有一版 `active`。** `edit` 一律作用于它,⛔ 不用倒着扫消息找「最后一张」。
2. **作废时机 = 新草稿生成时**,不是确认时。
3. **作废的卡片必须在页面上明确标注**:显示「已作废」,**确认按钮禁用**
⛔ 不许静默变灰、不许直接消失 —— 主管要能看见自己出过几版、以及哪一版才是当前的。
4. 刷新页面丢草稿是可接受的(重新出一版即可),**暂不落库**
---
## 八、模型的自主权边界
| ✅ 模型决定 | ⛔ 模型不许决定 |
|---|---|
| 理解自由输入(入口 C、节点外的话) | 哪个引导节点命中 |
| 要不要先查分布再圈 | 任何数字(A2) |
| 判断动的是「派法」还是「人群」 | 谁分给谁 |
| 组织语言(G6) | 引导节点的默认值 |
| 拿不准时反问(B2) | 是否落库(A3) |
---
## 九、不在本树内
| | 去哪 |
|---|---|
| **跟踪线** | 同会话,但解读规范随工具返回值加载(F10);节奏与分配完全不同 |
| **客服线** | 另一个身份 → 另一套工具清单与数据范围(D1–D4) |
| **患者个案查询 / 闲聊** | 自由通道,不进本树 |
---
## 十、随时可答:「下一步做什么」
主管在任何一步问「接下来呢 / 我现在该干嘛」,**答案是确定性的**,⛔ 不由模型即兴发挥:
```
下一步 = 当前状态 + 未处置的引导节点 + 默认路径
```
| 当前状态 | 下一步 |
|---|---|
| 还没圈定 | 在矩阵上点一格,或直接说要哪一类人 |
| 候选为 0 | 放宽一档 / 换治疗项 / 不限时间档 |
| 草稿 `active`,有需处置的节点 | 逐条列出未处置的节点 + 各自的默认;并说明「都不动也可以直接确认」 |
| 草稿 `active`,无需处置的节点 | 直接确认 |
| 草稿 `superseded` | 提示这版已作废,当前有效的是最新那版 |
| 已确认,无福利 | 可补挂福利;否则等客服处理,随时可跟踪 |
| 已确认,有福利 | 等客服处理;可跟踪;限时内可撤销 |
**这是 G1 的直接应用**:候选由程序算(状态机 + 引导节点是确定性的),措辞由模型给,决定权在人。
⚠️ 由于它完全由状态推出,**「下一步」永远不会指向系统做不到的事**(G2)。
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment