做AI代理有个很现实的问题:你辛苦攒了十几个prompt模板,结果代理一上线就四处漏风——有的不按格式输出,有的把产品卖点写成说明书,有的换了个场景就胡说八道。我上个月把团队在营销场景踩的坑汇总成了一个内部项目,代号Marketingskills,核心就一句话:把散养的prompt变成一套有边界、可复用、能评估的营销技能库。今天这篇就聊聊这个项目的技术拆解和实操过程,适合给AI代理设计技能体系的开发者,以及想在营销场景落地大模型应用的内容团队参考。
1. 项目整体设计与定位拆解
1.1 为什么需要“营销技能库”而不是散装Prompt
最早我们团队做营销内容生成,习惯是每个人维护几个prompt文件,互相复制粘贴,谁需要谁就改两句。结果就是同一个商品文案,A同事写的开头和B同事写的结尾完全不是一个语气,甚至同一个prompt在两个业务线被改出十几个版本。更要命的是,当AI代理需要在一个会话里连续完成“分析竞品 → 生成标题 → 提炼卖点 → 写正文”这一串动作时,散装prompt根本管不住状态,上一轮输出的字段下一轮就丢了。
这就好比工具箱里所有螺丝都堆在一个抽屉里,你要找一颗M4内六角,得把整个抽屉倒出来翻。而我想要的是一面洞洞板,每颗螺丝挂在固定位置,贴好标签,伸手就能拿,用完了还知道放回去。营销技能库就是这块洞洞板:每一个技能都有明确的输入输出边界、独立的执行逻辑和可重复调用的接口。AI代理不再靠“猜”来组合prompt,而是通过技能注册表查找、匹配、调用,像软件工程一样管理营销内容生成逻辑。
为什么散装prompt撑不住AI代理?原因有三个:
第一,意图识别不稳定。没有统一的技能清单时,代理只能靠LLM自己“揣测”该干什么,经常是问它“写个朋友圈推广”,它先给你讲了一通营销学理论,而不是直接输出文案。
第二,上下文污染严重。多个prompt共享同一个系统提示,用来约束风格的句子可能被另一个技能误读成事实,导致输出内容里混入莫名其妙的限定条件。
第三,无法量化质量。散装prompt的好坏全凭感觉,没法建立回归测试集,改一版prompt可能会影响所有下游任务,但没人知道。
所以我做Marketingskills时的第一个决定就是:所有营销能力必须以“技能”为单位做结构化建模,每个技能有名字、有描述、有输入schema、有输出schema、有执行函数,从源头掐死无边界复用。
1.2 技能库的五大核心模块
整个Marketingskills项目没有起高超的架构,就五个模块,各干各的事:
| 模块 | 职责 | 关键产出 |
|---|---|---|
| 技能注册表 | 存储所有技能定义,支持查询、校验、版本管理 | 技能Schema、技能清单 |
| 执行引擎 | 根据代理意图匹配技能,调度LLM或外部工具 | 调用日志、执行结果 |
| 上下文管理器 | 维护会话状态、品牌知识、历史消息,统一注入技能 | 结构化Context对象 |
| 评估管线 | 离线跑测试集,统计格式合规率、营销质量分 | 回归报告、评估指标 |
| 权限与审计 | 控制哪些代理可用哪些技能,记录调用链 | 权限策略、Trace ID |
这五个模块里,最容易被忽略的是“上下文管理器”。我刚开始以为只要把技能定义好,执行时把历史消息全塞进去就行,结果代理经常把上一轮生成的“竞品分析”当成下一轮的“品牌卖点”来用。后来我规定:上下文管理器只向技能注入该技能声明过的白名单字段,比如生成文案技能只需要用户意图、商品名、卖点列表、品牌语气,其他历史一律不传。这个约束让技能之间的数据流变得可控,也大大降低了代理“幻觉”的概率。
另一个关键点是权限与审计。营销场景里,有的技能面向全员开放,比如生成slogan;有的只能给特定团队用,比如基于投放数据做预算分配建议。权限控制的实现很简单:技能定义里加一个“allowed_roles”字段,代理在调用前先校验角色。审计方面,我要求每次技能调用都要记录trace_id,这样后续排查“这条文案为什么有这个奇怪句子”时,能从日志反查到是哪次输入、哪条prompt、哪个模型参数产生的。
1.3 技术选型:自研还是复用框架
项目启动时,团队里有人建议直接用某开源Agent编排框架,理由是生态成熟、代码少写一半。我纠结了一周,最后决定:框架可以借用,但核心的注册表和执行链路必须自研。
原因特别朴素。开源Agent框架擅长的是通用任务编排,比如“规划步骤 → 调工具 → 总结答案”。但营销技能库的特殊性在于它的输出要过“双重校验”:先是格式校验(必须符合JSON Schema),然后是业务校验(必须符合品牌调性、不能有违禁词、不能过度承诺)。通用框架通常只做前者,甚至连前者都做得不够严格,它们倾向于把结果直接丢给下一环节。
我最终采用的路子是:基于一个极轻量的并发调度库(只用了它的任务队列和超时控制),自己写技能注册表和匹配逻辑。这个取舍让我在最难调的“LLM选技能”环节保住了控制权。选技能我用的是“语义匹配+规则兜底”:先把用户意图做词法分析,找出候选技能,再把技能描述和用户请求一起丢给LLM做最终选择。如果LLM返回的技能名不存在,注册表就自动触发兜底策略,调用一个“通用对话”技能,而不是让代理直接报错。
2. 技能定义与实现细节解析
2.1 技能描述的结构化Schema
技能定义是整个项目的基石。我参照OpenAPI规范做了简化,每个技能必须包含五个字段:name、description、parameters_schema、output_schema、handler。下面是一个生成商品详情页文案的简化示例:
{ "name": "generate_product_copy", "description": "生成商品详情页文案,适合电商产品推广场景。输入商品名称和核心卖点,输出标题、正文、行动号召。", "parameters": { "type": "object", "properties": { "product_name": { "type": "string", "description": "商品名称,例如『无线降噪耳机Pro』" }, "selling_points": { "type": "array", "items": {"type": "string"}, "description": "核心卖点列表,每一项是一个具体功能点" }, "target_audience": { "type": "string", "description": "目标人群,例如『通勤上班族』" } }, "required": ["product_name", "selling_points"] }, "output": { "type": "object", "properties": { "headline": {"type": "string", "description": "一句话主标题"}, "body": {"type": "string", "description": "正文文案,300字以内"}, "cta": {"type": "string", "description": "行动号召文案"} } } }这里有两个容易踩坑的点。
第一个是description必须写“什么时候用、输入是什么、给什么东西”,而不是写“这是一个生成文案的技能”。因为LLM在选择技能时靠的是语义匹配,描述越具体,匹配准确率越高。我试过把description写成“用于生成商品文案”,结果代理在写活动邀请函时也选中了它,后面加了“适合电商产品推广场景”才明显好转。
第二个是参数schema不要写得太死。早期我把“target_audience”设为required,但如果代理没拿到人群信息,就会自己在参数里编一个“大众消费者”,反而误导文案。后来我把非关键参数全部设为optional,并允许参数值为空,交给技能内部的prompt去决定怎么处理。记住:schema是给模型看的边界,不是给业务方的需求文档,留一点弹性空间。
2.2 技能编排与上下文传递机制
技能编排听起来玄乎,其实就是解决三个问题:选哪个技能、参数从哪来、上下文给什么。
选技能这块,我采用两阶段策略。第一阶段是粗筛:用正则匹配用户请求里的关键词,比如“文案”“详情页”“朋友圈”这些词命中后生成候选技能列表。第二阶段是精排:把候选技能的名称和描述拼成一段文本,连同用户请求一起发给LLM,让LLM返回一个JSON,包含技能名和参数字典。
{ "tool_choice": "generate_product_copy", "arguments": { "product_name": "无线降噪耳机Pro", "selling_points": ["主动降噪", "30小时续航", "轻若无物"] } }这里有个细节:LLM返回的arguments经常把数组类型写成逗号分隔的字符串,这需要在校验层做类型归一。我写了一个preprocess函数,对每个字段按照schema里的类型做转换。
上下文传递机制是另一个让团队受益很大的设计。我定义了一个Context类:
@dataclass class Context: session_id: str user_intent: str brand: str | None = None product_info: dict = field(default_factory=dict) brand_voice: str = "专业、简洁、有温度" memory: list[dict] = field(default_factory=list)在执行技能前,上下文管理器会从全局会话中抽取与当前技能相关的字段,塞进Context,再传给技能的handler。每个技能的handler只接收这个Context,不能直接访问全局变量。这样做的收益是:所有技能的可测试性大幅提升,我可以直接构造一个Context来单测技能,不用去mock整个会话。
2.3 营销技能的质量评估维度
技能库上线前,需要回答一个核心问题:怎么证明这个技能比散装prompt好?我建立了三条评估维度,缺一不可。
第一,格式合规率。这是硬指标,必须是100%。技能output_schema定义了字段名、类型、必填项,只要有一个字段缺失或类型错误,整个调用就算失败。我写了一个校验器,把模型输出解析成JSON后逐个字段比对,不符合就触发重试。
第二,营销有效度。文本生成类技能很难自动化判断,我采用“人工评分+抽样AB”的方式。具体做法是:每次评估在测试集里随机选20条请求,让三个业务人员从“卖点清晰度”“情感感染力”“行动引导力”三个角度打分,取平均值作为该技能的得分基线。注意,这个基线必须和线上实际效果分开看,线下模拟永远替代不了真实投放。
第三,品牌一致度。营销内容的红线是不能说违禁词、不能过度承诺、不能偏离品牌语气。我用两种手段:静态词表过滤、动态风格向量对比。静态词表维护了“最先进”“百分百”“国家级”这类词,一旦命中就标记警告。风格向量对比是把生成文案与品牌历史优秀文案的embedding做余弦相似度,低于阈值则判定为“风格漂移”,打回重写。
下面是我用表格维护的评估看板示例:
| 技能名 | 格式合规率 | 营销有效分 | 品牌一致分 | 平均耗时 |
|---|---|---|---|---|
| generate_product_copy | 100% | 4.2/5 | 0.91 | 2.1s |
| generate_social_post | 100% | 3.8/5 | 0.87 | 1.8s |
| analyze_competitor | 100% | 4.0/5 | 0.90 | 3.5s |
形成这个看板之后,每次修改技能内部prompt,我都能直观看到哪些指标下降了,而不是凭感觉说“这次改得好像更顺了”。
3. 实操过程:从零搭建营销技能库
3.1 环境准备与基础依赖
这个项目的代码量不大,但需要的基础依赖比较明确。我用的是Python 3.11,依赖库包括:一个本地小工具库(用于JSON解析和schema校验),一个LLM客户端SDK(底层接入统一的模型网关),以及一个轻量并发工具(用于处理技能超时)。
这里不贴具体安装命令,因为模型网关和SDK不同项目差异很大。说一个辅助工具的选择:所有LLM客户端调用我都封装成同一个接口,方便切换不同模型。技能内部不直接调用模型SDK,而是通过一个llm_client.chat(messages, temperature=0.7)的接口,这样测试时可以用mock替换,线上也可以给不同技能配置不同的temperature。
3.2 核心代码:技能注册与调度
技能注册表的实现很直接,我用字典存储技能实例,注册时校验重名和schema合法性。
# skill_registry.py import json from dataclasses import dataclass, field from typing import Callable, Any @dataclass class Skill: name: str description: str parameters_schema: dict output_schema: dict handler: Callable[[dict, 'Context'], dict] class SkillRegistry: def __init__(self): self._skills = {} def register(self, skill: Skill): if skill.name in self._skills: raise ValueError(f"技能重名: {skill.name}") # 简单校验schema assert "type" in skill.parameters_schema, "参数schema必须指定type" assert "properties" in skill.parameters_schema, "参数schema必须包含properties" self._skills[skill.name] = skill def get(self, name: str): return self._skills.get(name) def list_skills(self): return [{"name": s.name, "description": s.description} for s in self._skills.values()] def validate_arguments(self, name: str, arguments: dict): """用参数schema校验并归一化类型""" schema = self._skills[name].parameters_schema required = schema.get("required", []) for r in required: if r not in arguments: raise ValueError(f"缺少必填参数: {r}") # 这里省略完整jsonschema校验实现 return arguments技能调度器是核心中的核心。它接收用户的原始请求,先粗筛候选技能,再让LLM做选择,然后执行并校验输出。
# dispatcher.py from skill_registry import SkillRegistry, Skill class Dispatcher: def __init__(self, registry: SkillRegistry, llm_client): self.registry = registry self.llm = llm_client def _candidate_skills(self, user_request: str): # 粗筛:按关键词命中 candidates = [] keyword_map = { "文案": ["generate_product_copy", "generate_social_post"], "详情页": ["generate_product_copy"], "竞品": ["analyze_competitor"], "朋友圈": ["generate_social_post"], } for keyword, skill_names in keyword_map.items(): if keyword in user_request: candidates.extend(skill_names) return list(set(candidates)) or ["generic_response"] def dispatch(self, user_request: str, context: Context): candidates = self._candidate_skills(user_request) # 精排:让LLM从候选列表里选一个,并提取参数 skill_descriptions = [ f"{name}: {self.registry.get(name).description}" for name in candidates if self.registry.get(name) ] prompt = f"""根据用户请求,从以下技能中选一个最合适的,输出JSON:{{"skill": "技能名", "arguments": {{...}}}}。 用户请求: {user_request} 技能列表: {", ".join(skill_descriptions)} 只输出JSON。""" raw = self.llm.chat([{"role": "user", "content": prompt}], temperature=0.0) parsed = json.loads(raw) skill_name = parsed.get("skill") if skill_name not in candidates: skill_name = "generic_response" skill = self.registry.get(skill_name) args = self.registry.validate_arguments(skill_name, parsed.get("arguments", {})) result = skill.handler(args, context) # 输出校验:这里简化,只做JSON类型校验 return {"skill": skill_name, "result": result}这段代码看起来简单,但有几个细节值得说。
_candidate_skills里的关键词映射需要持续维护。我每遇到一次误选,就把用户请求里的新词补进去。比如刚开始没有“详情页”关键词,代理把“写详情页”请求转发给了generic_response,后来加上这个词,误选率立刻下降。
LLM选技能时的temperature必须设为0或接近0,因为它不是在创作,而是在做分类任务。调高temperature会导致技能名出现拼写变体,比如返回“generate_productcopy”,白白增加容错逻辑。
3.3 接入AI代理的完整链路
技能库不和具体的代理实现绑定,它提供了一套执行接口。完整的链路是这样的:
- 代理收到用户消息,把它交给Dispatcher;
- Dispatcher调用上下文管理器,构建本次会话的Context;
- 进行技能匹配,执行技能;
- 技能handler返回结构化的result;
- 代理把result渲染成自然语言回复。
关键是第5步。很多代理失败是因为让同一个模型既选技能、又生成回复,导致模型把技能调用的JSON混在回话里。我采用“两段式生成”:第一段只做工具调用(temperature=0),第二段基于工具结果生成文案(temperature=0.8)。这样既保证了工具调用稳定,又不牺牲生成文案的多样性。
下面是一个实际调用后的日志片段:
[12:00:01] 收到请求: 给这款耳机写个详情页文案 [12:00:02] 粗筛候选: ['generate_product_copy', 'generic_response'] [12:00:03] LLM选择: generate_product_copy [12:00:05] 参数校验通过: product_name=无线降噪耳机Pro, selling_points=... [12:00:06] 调用handler,注入Context,包含品牌语气和历史消息 [12:00:08] 输出校验通过,生成result [12:00:09] 代理渲染最终回复这段日志每行都对应一个真实环节。如果后续出了线上问题,我可以快速定位是匹配错、参数错、模型输出错,还是渲染错。
3.4 效果验证与迭代方法
技能库和模型本身一样,需要持续迭代。我搭建了一个离线回归脚本,每次修改技能的prompt或schema,都跑一遍固定测试集,输出合规率和人工评分结果。这个脚本是整个项目的定心丸。
测试集维护了三档请求:第一档是高频典型场景(15条),比如“生成详情页文案”“写朋友圈推广”;第二档是边界场景(10条),比如“没有卖点信息”“用户要求写违规词”;第三档是干扰场景(5条),比如“问天气但提到文案”。每一档都有对应的期望输出类型和必须满足的约束条件。
回归脚本的核心逻辑很简单:逐条调用Dispatcher,把返回的result和期望schema做比对,生成通过/失败列表。人工评分每周做一次,抽最近新增或修改过的技能来评。这样做两周后,我发现一个明显规律:内部prompt里例子越多,营销有效分越高,但响应耗时也会上升。于是我给每个技能的prompt设置了“最多示例不超过3条”的限制,把省下来的时间留给了第二段生成。
4. 常见问题与排查技巧实录
4.1 技能之间互相干扰:命名空间与权限隔离
项目运行一周后,出现了一个诡异现象:调用“generate_social_post”生成朋友圈文案时,开头总会冒出“详情页主标题”这样的词。查日志发现,是因为两个技能的handler在底层复用了一个公共prompt拼接函数,函数里从某个全局字典读取“最近一次生成的标题”,结果被另一个技能写入了。
解决办法是强制技能间隔离:所有handler只允许访问自己schema里声明的参数,以及Context中明确提供的字段。我写了一个with_isolation装饰器,动态检查handler里用到的全局变量访问,发现跨技能引用就报错。同时,技能之间的prompt模板必须放在独立的文件或命名空间里,不允许使用一个global的prompt变量。
4.2 模型输出不遵循技能格式的兜底策略
再怎么prompt,模型偶尔还是会返回带Markdown的JSON、缺少结束括号的JSON,或者把日期格式写成“2024-5-1”而不是要求的“2024-05-01”。我设计了三层兜底。
第一层是解析容错。先把模型输出里的“json”和“”去掉,再把多余的解释文本截掉。我封装了一个safe_parse_json函数,尝试多种策略提取最可能的JSON片段。
第二层是信息保留。如果解析失败,程序不会直接报错,而是把原始输出当作“body”字段塞进result,并标记_parse_failed: true,让上层决定是否重试还是返回给用户。
第三层是二次生成。对于关键技能,我会把schema要求重新发给模型,让模型严格按照JSON格式重新输出,同时附带第一次的输出作为参考。实测下来,二次生成的成功率在80%左右。
这里有一个教训:不要无限重试。我最初设置了最多3次重试,但有一次模型持续烧钱,3次都失败也没能救回来。建议最多重试1次,失败后走降级方案,直接调用一个固定模板技能兜底。
4.3 营销内容风格漂移:如何固定品牌语气
风格漂移是最玄学的问题。同一个技能,上午生成的文案还有品牌调性,下午就变成产品说明书的语气,数据一查发现是上下文管理器注入的品牌语气字段被某次会话污染了。
我的解决方法是“语气锚点”。每个品牌在Context里有一段固定的brand_voice描述,但这段描述太长会占token,太短又不够约束。我把它做成两段式:一段是简洁的风格指令(比如“语气专业但不冷硬,多使用动词,少用形容词”),另一段是3条品牌历史优秀文案示例,存放向量数据库里。技能需要时通过embedding检索最相关的2条示例,放进上下文中。
这个方案的本质是把品牌语气从“一次性写死在prompt里”变成“可检索可更新的资产”。每次品牌调性调整,只需要更新向量库里的语料,所有技能自动生效,不用再逐个改prompt。
4.4 排查技能调用失败的工具化手段
当线上代理出现人工反馈“文案乱写”时,我需要快速判断是哪个环节出了问题。我给Dispatcher加了一个装饰器,记录每个技能调用的完整链路。这个装饰器做的事情很简单:在技能调用前记录输入,调用后记录输出和耗时,如果抛异常就记录堆栈。
import functools, time, traceback def trace_skill_call(func): @functools.wraps(func) def wrapper(*args, **kwargs): trace_id = kwargs.get("trace_id", "unknown") start = time.time() try: result = func(*args, **kwargs) logger.info({ "event": "skill_call_success", "trace_id": trace_id, "skill": func.__name__, "duration_ms": round((time.time() - start) * 1000, 2), "result": result }) return result except Exception as e: logger.error({ "event": "skill_call_failed", "trace_id": trace_id, "skill": func.__name__, "error": str(e), "stack": traceback.format_exc() }) raise return wrapper给每个技能handler都加上这个装饰器,配合trace_id,就可以在日志系统里按trace_id聚合所有相关日志,看到“Agent收到请求 → 调度器选择技能 → 调用handler → 输出校验”的全过程。这个能力在项目上线初期帮了大忙,很多“症状”看着像模型幻觉,实际是参数校验时类型转换bug导致的。
5. 后续扩展与个人实际体会
5.1 技能库向插件生态演进的思考
Marketingskills目前只服务内部营销团队,但我在设计之初就有意把注册表接口做得足够通用,期望未来可以像插件市场一样开放给更多团队。具体设想是:每个技能包可以是一个目录,里面包含schema.json和handler.py,由其他团队打包上传到内部仓库,注册表动态加载。
不过要提醒的是,插件化会增加安全和审核成本。技能是执行任意代码的,一旦开放,就要做沙箱隔离、资源限制和调用审计。我的建议是:先保持单体仓库模式,把技能库内部模块之间通过接口解耦,但物理部署暂时不要拆开。等技能数量超过50个、团队超过3个时,再考虑拆成独立服务。过早微服务化只会让你陷入分布式调试的泥潭,营销场景的核心矛盾是内容质量和调用稳定,不是并发规模。
5.2 踩坑后的几点心得
项目做到现在,我最大的体会是:AI代理技能库本质上是用工程方法管住模型的自由度。模型本身有很强的表达能力,但营销场景需要的是稳定、可控、可复用的输出,而不是每次都有惊喜。
如果你也要做类似的东西,我有几条比较实在的建议:
第一,技能描述要像程序员写接口一样严谨。不要把“一点小细节”留给模型自己悟,它真的会悟出奇怪的东西。
第二,不要一开始就把技能库设计得太宏大。我最初想支持技能依赖图、并行调度、多模型路由,后来全砍了,只留线性调用。实际上营销代理的任务链大多是串行:先分析、再生成、最后优化,极少需要并行。砍掉复杂功能后,系统稳定性反而上来了。
第三,建立黄金测试集比什么都重要。一个没有回归测试的技能库,就是一颗定时炸弹。你无法感知某次prompt微调对全局的影响,等用户投诉才发现就晚了。
第四,版本管理不要只盯代码,技能的prompt也要版本化。我把每个技能目录里放一个prompt_v2.txt,同时在注册表里记录当前激活版本,改坏了可以秒级回滚。
现在这个项目还在持续迭代中,下一步打算给技能库加一个“技能自动组合”的能力,让代理在完成复杂任务时,可以像搭积木一样把多个技能串起来。但那还需要更细粒度的技能依赖描述和更强的规划能力,等真正落地了我再回来聊。