Design: LLM Output Contract 与 Prompt 管理方式
Current legacy contract snapshot(2026-07-15):本文的 typed metadata / schema / writer enforcement 分层仍有参考价值,但具体 taskDecisions[]、typeCategory、global closeResult、record_progress 和 create_closed 示例只描述旧 contract,不是 Target。新 Task vocabulary、Task Policy 与 bounded reasoning loop 以 Task System Design V3 为准。
Date: 2026-06-23
Status: Draft
Scope: contacts-analyzer prompt / schema / common metadata / future call-task-contact AI outputs
背景
我们现在在整理 contacts-analyzer 的 prompt。当前最大的问题不是某一句 prompt 写得不好,而是职责混在一起:
- common 里有 taxonomy definitions。
- contacts-analyzer 里有
ContactsAnalysisSchema。
- prompt 里又手写了一份 output contract reminder。
- task playbook 有一份动态注入逻辑,但只覆盖 open task 场景。
这会导致 review 很痛苦:你不知道一句话到底是业务 policy、schema、taxonomy,还是为了弥补某个 writer 限制而写的 prompt hack。
最终目标是:schema / taxonomy / field metadata / writer behavior 有一个 typed source of truth,prompt 只是渲染这些 source of truth 的最小必要投影。
核心原则
-
DB schema 管存储事实
- 表、列、约束、index、enum storage value。
- 不负责教模型怎么判断。
-
common metadata 管 AI-facing meaning
- 每个 enum value 是什么意思。
- 每个 AI output field 是干什么的。
- AI 是否能输出,写到哪里,什么时候适用。
- writer 会不会 override 或 reject。
-
Zod / JSON schema 管 output shape
- required / optional / default / union / enum / pattern。
- 交给
generateObject / provider structured output。
- 不要再把完整 JSON schema 手写进 prompt。
-
prompt policy 只管判断流程
- 什么时候 create / close / create_closed / record_progress。
- 什么证据可信。
- 什么情况需要人工 review。
- 不重复定义 enum value,不重复写 JSON shape。
-
writer / Policy Guard 才有最终写入权
- AI output 是 proposal。
- DNC、store match、duplicate、stale proposal、hallucinated
taskId 等由 deterministic code 拦。
- prompt 里可以提醒,但不能把安全性主要寄托在提醒上。
最终分层
建议分成 4 层:
Layer 1: Common Typed Metadata
enum definitions / field specs / action specs / applicability / playbook metadata
Layer 2: Output Schema and Validation
Zod schema / JSON schema / structured output request body / parse validation
Layer 3: Authored Decision Policy
human-written role, purpose, evidence rules, contact policy, task decision rules
Layer 4: Runtime Context and Writer Enforcement
rendered user message, refs, open tasks, playbook snippets, writer, Policy Guard, DB constraints
模型真正收到的东西应该是:
这一层放什么
这一层应该在 @retaintive/common,因为它是多个 Lambda / prompt / UI / writer 都会用到的语义源头。
它应该包含:
- enum values。
- enum definitions。
- AI 可用的 allowlist。
- AI output field 的 ownership。
- field 到 DB column 的 mapping。
- field applicability。
- task action metadata。
- writer override / deterministic guard metadata。
- playbook metadata 的短版决策说明。
这一层不应该包含:
- 某个 Lambda 的完整 production prompt。
- 某个模型专用的措辞。
- 长篇 playbook 执行话术。
- UI display copy。
- DB query 或 writer SQL。
现在已有的例子
common 现在已经有这些基础:
// @retaintive/common/taxonomy/contact
CONTACT_LIFECYCLE_STAGE_DEFINITIONS
LEAD_STATUS_DEFINITIONS
PURCHASE_INTENT_DEFINITIONS
// @retaintive/common/taxonomy/task
TASK_TYPE_CATEGORY_DEFINITIONS
TASK_CLOSE_RESULT_DEFINITIONS
TASK_PROGRESS_TYPE_DEFINITIONS
TASK_CHANNEL_DEFINITIONS
AI_ASSIGNABLE_TASK_TYPE_CATEGORIES
AI_TASK_CLOSE_RESULTS
这些适合直接渲染成 prompt 中的 controlled vocabulary,例如:
lead_follow_up:
Lead is not booked yet and staff can still move them toward booking or the next sales conversation.
booked:
Lead confirmed an upcoming class, intro, or appointment; the booking objective is complete but membership conversion is not yet known.
未来可以在 common 加一个 bounded context,比如:
src/ai-metadata/
contacts-analysis/
fields.ts
task-actions.ts
applicability.ts
prompt-render.ts
字段 metadata 长这样:
export const contactAnalysisFieldSpecs = {
customerSummary: {
owner: 'ai',
outputPath: 'customerSummary',
dbTarget: { table: 'contacts', column: 'customer_summary' },
valueKind: 'free_text',
description: 'Staff-facing rolling summary of this customer.',
required: true,
maxGuidance: '3-5 concise sentences',
writerBehavior: 'accepted_after_schema_parse',
},
lifecycleStage: {
owner: 'ai',
outputPath: 'lifecycleStage',
dbTarget: { table: 'contacts', column: 'lifecycle_stage' },
valueKind: 'enum',
enumSource: 'CONTACT_LIFECYCLE_STAGE_DEFINITIONS',
required: true,
writerBehavior: 'accepted_after_schema_parse',
},
doNotContact: {
owner: 'ai+staff',
outputPath: 'doNotContact',
dbTarget: { table: 'contacts', column: 'do_not_contact' },
valueKind: 'boolean',
required: false,
preserveTrue: true,
writerBehavior: 'staff_or_existing_true_wins',
deterministicGuard: 'dnc_blocks_task_creation',
},
goals: {
owner: 'ai',
outputPath: 'goals',
dbTarget: { table: 'contacts', column: 'goals' },
valueKind: 'object_array',
required: false,
defaultValue: [],
taxonomySource: null,
note: 'No controlled vocabulary yet.',
},
} as const;
task action metadata 长这样:
export const taskDecisionActionSpecs = {
create_open: {
aiEmittable: true,
mapsToTaskAction: 'create_open',
requiredFields: ['action', 'typeCategory', 'suggestedActions', 'reason'],
optionalFields: ['sourceRefs'],
forbiddenFields: ['taskId', 'closeResult', 'progressType', 'channel', 'nextDueAt'],
typeCategorySource: 'AI_ASSIGNABLE_TASK_TYPE_CATEGORIES',
writerGuards: ['dnc', 'duplicate', 'store_mismatch', 'low_confidence'],
},
close: {
aiEmittable: true,
mapsToTaskAction: 'close',
requiredFields: ['action', 'taskId', 'typeCategory', 'closeResult', 'reason'],
optionalFields: ['sourceRefs'],
forbiddenFields: ['suggestedActions', 'progressType', 'channel', 'nextDueAt'],
taskIdSource: 'OPEN TASKS taskRef only',
closeResultSource: 'AI_TASK_CLOSE_RESULTS',
writerGuards: ['task_not_open', 'store_mismatch', 'stale_proposal'],
},
reopen: {
aiEmittable: false,
mapsToTaskAction: 'reopen',
reason: 'contacts-analyzer does not render closed task ids as mutable refs.',
},
} as const;
playbook metadata 短版长这样:
export const taskDecisionPlaybookSpecs = {
booked_not_converted: {
decisionObjective:
'Convert a completed intro/trial/class into a membership while the experience is fresh.',
createWhen:
'Customer completed or credibly attended intro/trial/class, has not reliably purchased, and staff still has a concrete next step.',
closeWhen:
'Customer purchased membership or clearly declined.',
suggestedCloseResults: ['converted', 'not_interested', 'unable_to_reach'],
},
referral: {
decisionObjective:
'Capture a concrete referred person or complete referral benefit processing.',
createWhen:
'Customer offers or discusses a specific referral that staff can act on.',
closeWhen:
'Referred contact is captured, booked, visited, or referral benefit is processed.',
suggestedCloseResults: ['referral_obtained', 'other'],
},
} as const;
注意:这不是完整 staff playbook。完整 staff playbook 应该服务 task detail / playbook tab。contacts-analyzer 只需要短版,帮助它判断任务是否存在、是否完成、怎么 close。
Layer 2: Output Schema and Validation
这一层放什么
这一层定义模型必须返回什么形状。
它应该包含:
- Zod schema。
- discriminated union。
- required / optional / default。
- enum constraints。
- string pattern,比如
T1 / C1 / M1 / L1。
- max array length 这类 sanity cap。
- parse / validation retry。
这一层不应该包含:
- 长篇业务判断规则。
- enum value 的重复解释。
- staff playbook。
- evidence authority。
现在的例子
contacts-analyzer 当前已经有 ContactsAnalysisSchema,并且 invoke 层把它传给 generateObject:
await generateObject({
schema: zodSchema,
mode: 'json',
system: opts.systemPrompt,
prompt: userMessage,
});
task decision union 现在大概是:
const TaskCreateOpenDecision = z.object({
action: z.literal('create_open'),
typeCategory: z.enum(AI_ASSIGNABLE_TASK_TYPE_CATEGORIES),
suggestedActions: SuggestedActionsListSchema,
sourceRefs: SourceRefsSchema,
reason: z.string(),
});
const TaskCloseDecision = z.object({
action: z.literal('close'),
taskId: TaskRefSchema,
typeCategory: z.enum(TASK_TYPE_CATEGORY),
closeResult: z.enum(AI_TASK_CLOSE_RESULTS),
sourceRefs: SourceRefsSchema,
reason: z.string(),
});
这已经足够表达 JSON shape。prompt 里不应该再手写:
create_open requires A/B/C and forbids D/E/F
除非某个模型经常犯错,需要临时加一条极短提醒。
目标形态
短期:
// infra keeps final Lambda-owned schema
export const ContactsAnalysisSchema = z.object({
customerSummary: z.string(),
lifecycleStage: LifecycleStageEnum,
taskDecisions: z.array(TaskDecision).max(20).default([]),
});
中期:
// common exports metadata, infra composes final Lambda-owned schema
export const ContactsAnalysisSchema = buildContactsAnalysisSchema({
fieldSpecs: contactAnalysisFieldSpecs,
actionSpecs: taskDecisionActionSpecs,
});
生产 prompt 只保留这一句:
Return only JSON matching the provided Zod schema. Do not include markdown or commentary.
Use only rendered refs such as T1, C1, M1, and L1. Never invent refs.
review/debug artifact 可以生成完整 contract:
DEBUG ONLY: Generated from ContactsAnalysisSchema
- customerSummary: required string
- lifecycleStage: required enum
- taskDecisions: array, max 20
但这份 debug artifact 不应该默认拼进 production system prompt。
Layer 3: Authored Decision Policy
这一层放什么
这一层是人写的判断规则。它告诉模型“什么时候用某个输出”,而不是“这个字段长什么样”。
它应该包含:
- role。
- overall purpose。
- evidence authority。
- contact profile 判断规则。
- task decision 判断规则。
- DNC / complaint / source evidence 的高层规则。
create_open / create_closed / close / update / record_progress 的选择逻辑。
这一层不应该包含:
- enum value definitions。
- task action JSON shape。
- DB column mapping。
taskId regex。
- 所有 lifecycle stage 的硬编码解释。
应该写:
先根据 evidence authority 判断这个人的当前 profile。
Current profile 是背景,不是绝对事实。
如果最新、明确、强证据说明 profile 已经变化,可以更新。
如果证据冲突或不够硬,不要编造确定结论,在 summary/reason 里说明 uncertainty。
不要编造 input 中没有的 pricing、contract、payment、attendance、staff promise。
不应该写:
lead means ...
member means ...
churned means ...
这些应该来自 common taxonomy definitions。
task decision policy 例子
应该写:
只有 staff 真的有下一步可以改变结果时,才 create_open。
如果最新 interaction 已经把业务目标完成了:
- 有 visible open task:close 那个 task。
- 没有 visible open task,但这个结果重要:create_closed 留 ledger。
- 不要再 create_open。
如果 staff 只是打了电话没人接、留了 voicemail、发了 text,而目标还没完成:
record_progress,不要 close 后再 create 一个同类 open task。
如果 input 没有显示可关闭的 open task,不要假装有 taskId。
不应该写:
create_open requires action/typeCategory/suggestedActions/reason and forbids taskId.
这是 schema/action metadata 的事。
evidence authority 例子
应该写:
判断证据时看 type、strength、freshness。
Intent / behavior 以最新明确互动为主。
Hard facts 以更强证据为主;没有 CRM 时,current profile 只是历史判断,不是绝对权威。
冲突不够硬时,不要静默覆盖,输出 uncertainty。
不应该在这里重复:
taskDecisions[] is max 20
这是 schema 的事。
Layer 4: Runtime Context and Writer Enforcement
这一层放什么
这一层是每次调用才知道的东西,以及模型输出后的 deterministic enforcement。
它应该包含:
- rendered contact snapshot。
- recent calls。
- recent messages。
- lead records。
- open tasks。
- recently closed tasks。
- source refs,比如
C1 / M1 / L1。
- open task refs,比如
T1 / T2。
- 动态 playbook snippets。
- schema parse。
- ref resolution。
- Policy Guard。
- Task Orchestrator。
- DB constraints。
- audit / logs。
这一层不应该包含:
- global enum definitions。
- static role/purpose。
- long policy prose。
user message 例子
CURRENT CONTACT SNAPSHOT:
- lifecycleStage: lead
- leadStatus: booked
- doNotContact: false
- customerSummary: Asked about intro class and booked Tuesday.
RECENT CALLS:
- C1 2026-06-23 inbound
Customer said she completed the intro class and wants pricing for membership.
Staff explained membership options and customer said she will sign up tomorrow.
OPEN TASKS:
- T1 [BOOKED_NOT_CONVERTED]
Suggested: "Call after intro class to convert to membership"
src: call 2026-06-20 (not shown)
PLAYBOOK GUIDANCE:
- [BOOKED_NOT_CONVERTED] Convert completed intro/trial/class into membership. Close converted only when purchase is reliable.
模型可能输出:
{
"customerSummary": "Completed intro class and is actively considering membership pricing.",
"lifecycleStage": "lead",
"lifecycleState": "active",
"leadStatus": "trialed",
"purchaseIntent": "high",
"taskDecisions": [
{
"action": "record_progress",
"taskId": "T1",
"typeCategory": "booked_not_converted",
"progressType": "customer_considering",
"channel": "phone",
"sourceRefs": ["C1"],
"reason": "Customer completed the intro class and is considering membership pricing, but has not purchased yet."
}
]
}
writer 做的事:
1. Parse with ContactsAnalysisSchema.
2. Resolve taskId "T1" to real UUID from rendered OPEN TASKS map.
3. Resolve sourceRefs ["C1"] to real call/message/lead ids.
4. Convert proposal to common TaskAction.
5. Run applyTaskAction().
6. Policy Guard rejects if DNC/store mismatch/task_not_open/stale/duplicate.
7. Only accepted actions produce SQL writes.
create_closed 例子
user message:
RECENT CALLS:
- C1 2026-06-23 outbound
Staff called a former member. Customer said she wants to return.
Staff reactivated her membership during the call.
OPEN TASKS:
none
模型应该输出:
{
"customerSummary": "Former member returned and was reactivated during the latest call.",
"lifecycleStage": "member",
"lifecycleState": "active",
"leadStatus": "converted",
"taskDecisions": [
{
"action": "create_closed",
"typeCategory": "win_back",
"closeResult": "win_back",
"sourceRefs": ["C1"],
"reason": "The win-back objective was opened and completed in the same call."
}
]
}
为什么不是 create_open:
业务目标已经完成。staff 没有下一步能改变结果。
但这是重要 revenue outcome,所以要留 closed ledger。
record_progress 例子
user message:
OPEN TASKS:
- T1 [LEAD_FOLLOW_UP]
Suggested: "Call lead to book intro class"
RECENT CALLS:
- C1 outbound
Staff called. No answer.
模型应该输出:
{
"taskDecisions": [
{
"action": "record_progress",
"taskId": "T1",
"typeCategory": "lead_follow_up",
"progressType": "no_answer",
"channel": "phone",
"sourceRefs": ["C1"],
"reason": "Staff attempted the follow-up call but did not reach the lead."
}
]
}
为什么不是 close:
一次 no answer 只是过程事实,目标还没完成。
为什么不是 close 后 create 新 task:
同一个业务目标还在继续,用 progress event 更新已有 open task。
Task Playbook 应该怎么放
现在 task playbook 的问题是:它只根据 open tasks 的 typeCategory 动态注入 user message。也就是说:
- 有 open task:模型能看到对应 guidance。
- 没有 open task:模型创建新 task 时,看不到 category-specific playbook guidance。
建议目标态:
用于 contacts-analyzer 判断:
export const taskDecisionPlaybookSpecs = {
cancellation_risk: {
objective: 'Save membership before cancellation/freeze/downgrade is final.',
createWhen: 'Member expresses cancellation/freeze/downgrade risk and staff can still intervene.',
progressWhen: 'Staff attempted save conversation but customer is still considering.',
closeWhen: 'Customer stayed, cancellation completed, or customer clearly declined further help.',
closeResults: ['cancel_saved', 'cancelled', 'not_interested', 'unable_to_reach'],
},
} as const;
渲染给模型:
[CANCELLATION_RISK]
Create when member expresses cancellation/freeze/downgrade risk and staff can still intervene.
Close when saved or cancellation is clearly proceeding.
Use record_progress when staff attempted save work but outcome is still unresolved.
2. 执行长版 playbook 放 task playbook surface
用于 staff 打开 task detail 时看:
{
"primaryAction": "Call the member and ask what is driving the cancellation request.",
"recommendedSteps": [
"Acknowledge the request.",
"Ask one clarifying question about the reason.",
"Offer approved save options."
],
"avoid": [
"Do not invent pricing.",
"Do not promise policy exceptions."
],
"closeGuidance": "Close cancel_saved only if the customer confirms they are staying."
}
3. prompt-builder 负责动态选择
user message 可以注入两类 playbook snippets:
OPEN TASK PLAYBOOK GUIDANCE:
- snippets for visible open tasks
CREATE CANDIDATE GUIDANCE:
- snippets for categories deterministically suggested by recent evidence
如果 deterministic pre-selection 不可靠,可以先渲染所有 AI-assignable category 的极短版 guidance。它们每条必须很短,否则 prompt 会被 playbook 淹没。
每个地方最终包含什么
迁移计划
Phase 1: 先让 production prompt 不重复 schema
改动:
- 删除或停用
output-contract-injected.ts 作为 production system prompt section。
output-contract.ts 只保留 debug/review renderer,或者先删掉。
- system prompt 只保留一条短 output instruction。
- snapshot test 更新。
目标效果:
Production prompt:
Return only JSON matching the provided Zod schema. Do not invent refs.
而不是:
create_open requires ...
create_closed requires ...
close requires ...
改动:
- 在 common 加
ai-metadata 或扩展 taxonomy bounded context。
- 定义 contact output field specs。
- 定义 task action specs。
- 定义 task decision playbook specs。
- 加 coverage tests,保证 metadata 覆盖 schema/taxonomy。
目标效果:
expectExactKeys(contactAnalysisFieldSpecs, ContactsAnalysisSchema.shape);
expectExactKeys(taskDecisionActionSpecs, TASK_ACTION_NAMES);
改动:
- taxonomy sections 从 common definitions 渲染。
- field applicability 从 common metadata 渲染。
- action reminders 从 common metadata 渲染,但 production 只渲染最小版本。
目标效果:
GENERATED TASK VOCABULARY
- lead_follow_up: ...
- booked_not_converted: ...
TASK DECISION POLICY
- Create open only when staff has a real next step.
- Use create_closed for settled in-interaction revenue outcomes.
短期可以保留 Lambda-owned final schema,因为 common placement guide 当前就是这么要求的。
中期可以变成:
export const ContactsAnalysisSchema =
buildContactsAnalysisSchema(contactAnalysisOutputSpec);
注意:即使 schema builder 在 common,具体某个 LLM call 的最终 output object 仍然可以由 consumer repo 组合。关键是不要手写第二份 field/action contract。
Phase 5: playbook 分层
改动:
- decision short playbook 进入 common metadata 或 tenant pack。
- staff execution long playbook 留在 task playbook surface。
- prompt-builder 同时支持 open task snippets 和 create candidate snippets。
目标效果:
模型判断 task 用短 guidance。
staff 执行 task 用长 playbook。
两者来自同一份 category/objective metadata,不互相复制。
最终 review 方式
以后 review prompt 时,不应该只看一个大 system prompt。应该看四份东西:
-
Authored policy review
- role / purpose / evidence / task decision policy 是否像人话。
-
Generated metadata review
- common taxonomy / field metadata / action metadata 是否准确。
-
Schema review
- Zod schema 是否表达了 required/optional/union/default/ref pattern。
-
Runtime context review
- user message 是否把 contact/calls/messages/tasks/playbook refs 渲染清楚。
review pack 可以长这样:
00_runtime-parameters.md
01_system-authored-policy.md
02_generated-taxonomy-and-metadata.md
03_request-schema.json
04_user-message-example-redacted.md
05_writer-policy-guard-summary.md
这个 review pack 是生成物,不是 source of truth。
当前代码里的对应关系
Verified 2026-06-23 with rg / sed in this repo.
lambda/shared/utils/ai/invoke.ts 调用 generateObject({ schema: zodSchema, mode: 'json', system, prompt })。
lambda/contacts-analyzer/src/core/models.ts 定义 ContactsAnalysisSchema 和 TaskDecision union。
lambda/contacts-analyzer/src/core/prompts/output-contract.ts 当前手写了 contact field notes 和 task action contract reminders。
lambda/contacts-analyzer/src/core/prompts/output-contract-injected.ts 当前把这些 reminders 作为 prompt section 导出。
lambda/contacts-analyzer/src/core/prompts/task/playbook-guidance.ts 当前按 open task category 提供短 playbook guidance。
lambda/contacts-analyzer/src/core/prompt-builder.ts 当前把 open task playbook guidance 插进 user message。
@retaintive/common/taxonomy/contact 和 @retaintive/common/taxonomy/task 当前已有 enum definitions。
@retaintive/common/domain 当前已有 TaskAction / applyTaskAction / Policy Guard 方向。
外部参考: