很多人第一次拿到这类开发平台的文档,看到"03.02.02.01 Add a Simple Action"这种编号,第一反应是:这不就是"加一个按钮、写一个回调"嘛?等真上手才发现,Action在整个对话式AI平台里承担的角色,比你想象的要重得多——它不只是"执行一段代码",而是意图识别之后、用户感知之前的那一整段逻辑的承载者。
我最早接触这个功能,是想给助手加一个查询订单状态的快捷操作。当时走了不少弯路,对Action的定位、生命周期、参数传递方式都理解得很浅,导致写出来的Action要么拿不到参数,要么响应格式不对,调试了大半天。这篇东西就是把我从零到一跑通"添加一个简单的操作"的完整过程、原理和踩坑记录整理出来,给正要碰这块的开发者做一个能直接照着做的参考。
1. 先搞清楚Action到底是什么:它不是回调,是一段完整的意图处理链
在动手写代码之前,我觉得最值得花时间的地方,是先把Action在整体架构里的位置弄清楚。很多文档只告诉你"Action是一个可执行的操作",但这个解释对实际开发几乎没有帮助。
1.1 Action在"用户请求→平台响应"链路中的真实位置
我把一次完整的人机交互在平台内部的流转拆开看,大概是这样一个链路:
- 用户说了一句话(比如"帮我查一下订单状态");
- 平台的自然语言理解模块(NLU)对这句话做意图识别和槽位提取,得到意图名称(比如query_order_status)和参数(比如order_id=10086);
- 平台根据意图配置,查找到对应的Action;
- Action被触发,执行你写的业务逻辑,可能去调数据库、调第三方接口,或者做本地计算;
- Action把结果封装成平台规定的响应结构,返回给对话管理模块;
- 对话管理模块根据响应内容,决定是直接回复用户、还是追问缺失参数、还是进入下一个流程。
这里最关键的一点是:Action的输入不是用户的原话,而是NLU处理之后的结构化数据。也就是说,如果你的Action拿不到预期参数,问题大概率出在意图配置、槽位定义上,而不是你的Action代码里。这个认知能帮你省掉大量无意义的调试时间。
1.2 "Simple Action"在本平台里具体指什么
不同平台对Action的叫法略有不同,有的叫"Skill"、有的叫"Function"、有的叫"Plugin",但核心概念是通用的。在"03.02.02.01"这个章节所对应的平台里,Simple Action指代的是:开发者通过声明式配置和少量代码,将一段自定义逻辑暴露为对话系统可调用的操作。
它和"复合操作"(复合Action)最大的区别在于:Simple Action不关心多轮对话状态管理,不涉及分支跳转,只是完成一个"输入→处理→输出"的闭环。你可以把它理解为对话系统里的一个"函数",而复合Action是"函数编排"。
打个比方:Simple Action就像餐厅里的一道固定套餐——顾客点了,后厨按标准流程做完端上来,完事。复合Action则像一个自助餐流水线,顾客可以自己选菜、选烹饪方式,后厨根据你的选择动态调整流程。
1.3 搞清楚Action的类型,别一上来就写代码
我见过不少同事(也包括最早的我)犯同一个错误:拿到需求就开始写代码,写完发现平台根本不认这个Action——因为Action需要在平台侧先注册、声明参数和触发条件,代码只是其中一部分。
在平台的常规设计里,一个Action通常由三部分组成:
- 清单文件(manifest):声明Action的名称、描述、输入参数、输出参数、权限要求等元信息。这是平台的"登记册",没有登记,平台不会把你的代码当成一个合法Action;
- 业务逻辑代码:真正干活的部分,接收参数、执行业务、返回结果;
- 资源文件(可选):如对话模板、错误提示文案等。
这三者缺一不可。很多新手在"添加Action"这一步卡住,就是因为只写了代码,没有做清单声明。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 动手前要备好的环境和工程结构:这些细节决定了你后面少踩多少坑
环境准备这块,文档里通常只有一句"请确保已安装XX SDK",但实际操作中,有几个细节是文档不会告诉你的。我把自己跑通的环境和工程结构列在下面,照着做基本不会出问题。
2.1 开发环境版本组合(实测兼容的组合)
先说明:以下版本组合是我当时实测可用的,不一定是最新,但理论上向后兼容。如果你用的是更新的版本,只要API没有breaking change,问题不大。
| 组件 | 版本 | 说明 |
|---|---|---|
| Python | 3.9+ | 官方SDK对3.10、3.11的支持也正常,别用3.6以下的就行 |
| 平台CLI | 2.x | 用于创建工程、注册Action、本地调试 |
| SDK | 对应CLI同版本 | 不建议混用大版本 |
| 本地的模拟器/调试器 | 随CLI安装 | 用于本地模拟对话,验证Action逻辑 |
有一个小点:一定要在虚拟环境里装SDK,不要图省事装到全局。因为平台SDK的依赖链不算短(通常会有grpc、protobuf这一类的依赖),装在全局很容易和你的其他项目产生版本冲突。我自己的习惯是启动每个新项目前先python -m venv .venv,再激活虚拟环境操作。
2.2 建议的工程目录结构
一个规范化的Action工程目录,通常长这样:
code复制my_actions_project/
├── manifest.json # Action的清单声明文件,核心
├── actions/
│ ├── __init__.py
│ ├── query_order_status.py # 你的Action逻辑代码
│ └── common/
│ ├── __init__.py
│ └── api_client.py # 封装第三方API调用
├── resources/
│ ├── prompts/
│ │ └── query_order_status.md # 对话模板,可选
│ └── errors/
│ └── zh-CN.json # 错误提示文案
├── tests/
│ ├── test_actions.py
│ └── fixtures/
│ └── sample_payloads.json
└── requirements.txt
这个结构不是平台强制的,但建议按这个习惯来。原因有三:
一是职责分离。manifest和代码分开,将来如果只改参数声明,不需要动到代码文件,评审和排查都方便。
二是为将来加Action做准备。一个项目大概率不会只加一个Action,按"一个Action一个模块文件"的约定,后续扩展就是复制文件、改逻辑的事,耦合度低。
三是本地测试容易。代码和依赖分层清楚后,写单测、造fixture数据都会顺手很多。
2.3 意图和槽位:Action的"触发条件"和"入参来源"
Action不是凭空被调用的,它需要被"意图"触发。在平台后台(或CLI配置中),你需要先定义一个意图,并在意图里声明槽位(也就是参数)。
以"查询订单状态"为例,你要先配置:
- 意图名称:
query_order_status - 训练语料:至少5~10句(如"我的订单到哪了""查一下单号10086的物流""帮我看看XX订单发了没")
- 槽位定义:
order_id(必填)、customer_name(选填)
然后在Action的manifest里,声明triggers.intent = "query_order_status",并在parameters里声明你需要接收的槽位。
这一步最好先做,再做代码。因为代码里的参数接收逻辑,必须和意图槽位定义严格一致——包括大小写、命名风格(snake_case还是camelCase)。平台在做参数传递时通常也会做一定程度的归一化(比如把orderId转成order_id),不同平台的策略不一样,但最稳妥的做法是:你自己在意图和manifest里用同一套命名,不要指望平台帮你转换。
3. 核心实现:从manifest声明到业务代码的完整说明
现在进入正题。这一节我会用一个"查询订单状态"的实例,把添加Simple Action的全部代码走一遍。每一步都会说明"为什么这么做",而不是单纯贴代码。
3.1 第一步:编写manifest.json并理解每个字段的作用
json复制{
"name": "query_order_status",
"description": "根据订单号查询订单的物流状态和预计送达时间",
"version": "1.0.0",
"triggers": {
"intent": "query_order_status"
},
"parameters": [
{
"name": "order_id",
"type": "string",
"required": true,
"description": "用户提供的订单号"
},
{
"name": "customer_name",
"type": "string",
"required": false,
"description": "用户姓名,用于校验订单归属"
}
],
"outputs": [
{
"name": "order_status",
"type": "string"
},
{
"name": "estimated_delivery",
"type": "string"
}
],
"timeout_ms": 3000,
"permissions": ["order:read"]
}
这个文件里,有几个字段值得展开说一下:
triggers.intent:这个字段决定了你的Action在什么时候被调用。只要NLU识别到用户意图是query_order_status,平台就会把控制权交给这个Action。一个Action只能绑定一个主意图,但多个Action可以监听同一个意图(这时平台会按优先级或其他策略选择执行哪个,后面再说)。parameters:这里声明的是"我想从对话里拿什么信息"。注意,字段名必须和NLU槽位名严格一致。如果你在这里写orderId,而槽位名是order_id,那运行时就会拿不到值。required:标记为必填的参数,平台会在多轮对话里自动追问用户。这就是"对话式"体验的核心机制之一——用户第一句话没说全订单号,平台不会强行调用你的Action,而是会反问"请提供您的订单号"。这个追问逻辑是平台内置的,不需要你写代码。timeout_ms:Action的执行超时时间。建议根据你的下游接口耗时来定,如果调第三方接口,一般3000ms是合理值;如果只是本地计算,可以设置更短(比如1000ms)。超过这个时间,平台会直接向用户返回超时提示,不会再等你的代码返回。permissions:这个字段不是所有平台都有,但如果你所在的平台有权限体系,建议在第一次开发时就规范声明,否则后续如果要上线审批,会因为没有权限声明被打回。
3.2 第二步:实现Action处理逻辑(Python代码)
写Action逻辑的基本套路是:继承SDK提供的基础类,然后实现处理函数。下面是一个完整的示例:
python复制import json
import logging
from datetime import datetime
from platform_sdk import ActionBase, ActionRequest, ActionResponse
logger = logging.getLogger(__name__)
class QueryOrderStatusAction(ActionBase):
"""查询订单状态Action"""
def handle(self, request: ActionRequest) -> ActionResponse:
order_id = request.parameters.get("order_id", "").strip()
customer_name = request.parameters.get("customer_name", "").strip()
if not order_id:
return ActionResponse(
success=False,
message="缺少订单号,请提供订单号后重试。"
)
# 调用下游订单服务的API,获取订单状态
try:
order_info = self._fetch_order_info(order_id, customer_name)
except OrderNotFoundException:
logger.warning("order not found: %s", order_id)
return ActionResponse(
success=False,
message="没有查询到该订单,请确认订单号是否正确。"
)
except Exception as exc:
logger.exception("failed to fetch order info: %s", exc)
return ActionResponse(
success=False,
message="查询订单状态时服务暂时不可用,请稍后再试。"
)
return ActionResponse(
success=True,
outputs={
"order_status": order_info["status"],
"estimated_delivery": order_info["estimated_delivery"]
},
message="订单当前状态为{},预计{}送达。".format(
order_info["status"], order_info["estimated_delivery"]
)
)
def _fetch_order_info(self, order_id: str, customer_name: str) -> dict:
# 这里调用你在common/api_client.py里封装的HTTP API
from actions.common.api_client import query_order
return query_order(order_id=order_id, customer_name=customer_name)
这个代码里隐含了几个关键点,逐一说一下:
第一,request.parameters 拿到的参数类型。 不同平台上,这个对象可能是字典、可能是属性访问器。如果是字典,建议先调用.get()而不是直接request.parameters["order_id"],因为一旦参数缺失会抛KeyError,而平台通常不会把这种异常当成"业务逻辑返回"处理,可能会直接报500错误。用.get()加默认值,至少能让代码更健壮。
第二,返回结构中的success字段不是摆设。 它不只是给日志看的,而是会影响对话系统的下一步行为。success=False时,平台通常会读取你返回的message,把这句话作为兜底回复发给用户;而success=True时,平台会优先使用outputs里的数据,配合对话模板生成最终的回答。所以,success字段的语义一定要把握好——它不是"你的代码有没有跑通",而是"用户的需求有没有被满足"。
第三,超时和异常必须显式处理。 一个Action的执行时间窗口通常很短(几百毫秒到几秒)。如果你的下游API比较慢,建议在_fetch_order_info内部设置请求级别的超时(比如2秒),并做好降级方案。不要指望平台的任务队列帮你的API调用兜底——平台只兜底"Action整体超时",不兜底"下游接口慢"。
3.3 第三步:注册Action并做本地验证
代码写完后,需要用CLI在本地工程里注册Action:
bash复制# 指定项目根目录并注册Action
platform-cli action register --project ./my_actions_project
# 查看注册是否成功
platform-cli action list
注册成功的标志是:action list里能看到query_order_status,并且状态为active。
然后做本地模拟对话验证:
bash复制platform-cli dialogue --project ./my_actions_project --query "帮我查一下订单10086的状态"
如果一切正常,你会看到类似这样的输出:
code复制意图识别:query_order_status
槽位提取:order_id=10086
调用Action:query_order_status
Action返回:success=True, outputs={"order_status": "已发货", "estimated_delivery": "2025-03-10"}
最终回复:订单当前状态为已发货,预计2025-03-10送达。
注意,本地模拟时,平台不会真的去调用你部署在服务器上的服务,它运行的是一个本地沙箱。也就是说,你在_fetch_order_info里如果连的是测试环境数据库或第三方Mock服务,只要网络可达,本地模拟是可以完整跑通的。这一步能帮你验证逻辑,但还远不能等于线上验证。
4. 编译部署与联调验证:从"本地能跑"到"线上可用"的距离
很多人在本地模拟一切正常,一上到测试环境就各种诡异问题。这一节专门讲从本地到测试环境的这段路上最容易出问题的地方。
4.1 编译打包:隐藏的"静态检查"环节
平台通常不会直接运行你本地工程目录下的源码,而是要求先做一次编译打包:
bash复制platform-cli build --project ./my_actions_project --output ./dist
如果平台用的是Python,这步可能会做字节码编译;如果平台使用的是其他运行时,可能会做依赖收集。但无论哪种情况,这次build都是对代码的一次"静态体检"——manifest里声明的参数是否和代码里访问的一致、依赖是否完整导出,都会在这里暴露出来。
一个很常见的坑:你在本地代码里用了requests库,但requirements.txt里漏掉了。本地跑的时候因为全局环境装有requests,一切正常;build时平台收集依赖发现缺了,构建失败。所以,build之前自己先核对一遍依赖声明,别指望平台的报错信息能帮你自动定位——报错信息往往没那么精准。
4.2 部署到测试环境并观察真实行为
bash复制platform-cli deploy --project ./my_actions_project --env staging
部署成功之后,一定要做两件事:
第一件,在平台后台或管理API里确认Action状态。有时候部署返回成功,但Action因为manifest校验不通过而处于inactive状态。这种"半成功"状态是最坑的,因为对话系统不会告诉你"我看到了这个Action但我没有激活它",直接就是调用不到。
第二件,用真实的对话入口测试。本地CLI和测试环境的NLU模型不一定完全一致——测试环境的模型可能用了更多语料训练,识别结果会有细微差异。我遇到过的情况是:本地模拟能正确识别query_order_status意图,但测试环境把"查一下订单"这句话识别成了另一个相似的意图,导致Action根本没被触发。这种问题只能在真实环境里发现。
4.3 联调中的日志用法:用request_id串起完整链路
联调阶段一定要学会看日志。平台在对一次对话的处理过程中,通常会生成一个全局唯一的request_id(有的平台叫session_id或conversation_id)。这个ID是整个链路的灵魂:
- NLU层日志里,会有
request_id对应的意图识别结果; - Action运行时日志里,会有同一个
request_id对应的入参、出参; - 如果下游API也做了透传,你在下游日志里也能用同一个
request_id串起整条链路。
我习惯在Action代码里,通过logger把request_id和关键参数一起打出来:
python复制logger.info("[%s] query_order_status called, order_id=%s, customer_name=%s",
request.request_id, order_id, customer_name)
这样一旦线上出现问题,拿到用户反馈里的时间戳,去日志平台按request_id一搜,三段日志全部浮出来,定位效率是肉眼可见的提升。
4.4 安全校验:Action层不可忽略的"三道闸门"
我第一次写Action的时候,觉得反正是内部服务调用,安全校验可以省了。后来被安全评审打回,才补上了这一课。在Action层,至少要做三道闸门:
第一道,参数合法性校验。比如order_id如果预期是纯数字字符串,就用正则做一次校验;如果预期长度是固定值或区间,也要显式校验。别把校验压力全部推给下游API——下游API未必有完备的入参校验,而且即使有,多一次前置校验可以让问题在更早的环节暴露。
第二道,下游接口鉴权信息不要硬编码在代码里。平台的配置中心(或者环境变量注入)是你存放ak/sk的正确位置。我见过把AccessKey写在代码注释里的案例,这个习惯非常不好。一是代码仓库的可见范围比你想的要大,二是Action代码一旦要打包分发,密钥就跟着泄漏了。
第三道,对下游返回的数据做"消毒"。不是所有下游接口都靠谱,如果下游返回的字段缺失或者类型不对,你的Action代码要做好兜底。比如下游返回的estimated_delivery是个空字符串,你的对话模板直接拼接就会生成"预计送达"这样的病句,非常影响体验。这种情况可以在代码里显式判断:
python复制if not order_info.get("estimated_delivery"):
estimated = "待更新"
else:
estimated = order_info["estimated_delivery"]
别看这是小事,真实用户的感受差异是很大的。
5. 从Simple到可维护:几个值得做的增强设计
如果只是跑通一个Demo,前面的内容已经够了。但如果你做的是要长期维护的生产级功能,下面这几个增强点建议在第一次开发时就考虑进去。
5.1 错误信息的"对话友好化"设计
Action的message字段是直接面向用户的,所以文案设计不能太程序化。我早期写的是"系统内部错误,请联系管理员"这种话,用户看了不明所以,运营也无奈。后来改成"查询服务暂时繁忙,请稍后重试,或拨打客服电话400-XXX-XXXX",用户接受度高很多,客服工单量也少了。
这里的核心思路是:错误文案要区分"用户的错"和"系统的错"。用户给错订单号,文案要引导用户检查输入;系统超时或下游故障,文案要给用户替代方案。这两种文案的语气和措辞应该是不同的。
5.2 引入简单的缓存,降低下游压力
如果订单查询接口对实时性要求不是极高(比如物流状态允许几分钟内的滞后),可以考虑在Action里加一层本地缓存:
python复制import time
_cache = {}
_CACHE_TTL_SECONDS = 120
def _get_cached(key):
item = _cache.get(key)
if item and time.time() - item["ts"] < _CACHE_TTL_SECONDS:
return item["value"]
return None
def _set_cache(key, value):
_cache[key] = {"value": value, "ts": time.time()}
注意,Action的运行环境可能是多实例的,本地的进程内缓存只对当前实例生效,所以缓存命中率不一定高。但即使如此,对于热点订单号的重复查询,还是能明显减少下游压力。如果要做更彻底的缓存,就要引入Redis这类外部组件,但那已经超出"Simple Action"的范畴了,这个可以后面讲到复合操作时再展开。
5.3 与"事件回调"的握手:异步任务的确认机制
如果你的Action会触发一个异步任务(比如"下单"这种需要长时间处理的操作),那么一个容易被忽略的细节是:Action返回成功,不代表任务提交成功。平台的事件回调机制通常会要求Action在上游事件到达时返回一个确认(ack),否则平台会认为事件投递失败,进入重试逻辑。
python复制def on_event(self, event: EventRequest) -> EventResponse:
# 先处理事件
self._process_event(event)
# 再确认收到
return EventResponse(ack=True)
这里的顺序很重要:先处理再确认。如果你先返回ack再去后台异步处理,一旦进程崩溃,这个事件就永久丢失了。反之,如果处理失败,可以返回ack=False,让平台继续重试。这个"先处理后确认"的习惯,是我在踩过几次丢事件的坑之后养成的。
6. 我把踩过的坑都列在这里:排查手册
最后这部分是实打实的踩坑记录。我把自己在开发Simple Action过程中遇到过的所有问题,按"症状→原因→解法"的结构整理出来,当排查手册用。
6.1 Action不触发:先查意图注册,再查触发条件
症状:日志里没有任何Action调用的痕迹,模拟对话时平台直接返回"抱歉,我还不理解您的意思"。
排查链路:
- 确认意图是否已在对应环境注册,且状态为
active。常见原因是:代码合到staging分支,但NLU训练模型还是老版本,没有包含新增的意图。 - 用NLU调试工具(CLI里通常有
dialogue --debug)看识别结果。如果识别出来的意图不是预期值,说明训练语料覆盖不足,需要补充语料。 - 如果意图识别正确,再看Action的状态是否是
active。部署成功不等于激活成功,这是两个状态。
6.2 参数一直为空:命名不一致是头号嫌疑
症状:Action被触发,但request.parameters里拿不到任何值。
排查链路:
- 对比manifest里
parameters的name和NLU槽位名是否完全一致。最常见的是orderId和order_id这种大小写/风格差异。 - 查看日志中NLU输出的槽位提取结果。如果槽位本身为空,说明用户说的话里确实没有提供该信息,或NLU没能识别出来。这时可以检查训练语料里是否覆盖了该槽位的不同表达方式。
- 有些平台会自动做参数映射,但千万不要依赖这个功能。做映射时一旦有歧义(比如两个意图都有
date槽位但含义不同),平台会倾向于不映射,导致参数为空。
6.3 Action超时:同步请求占了太多线程
症状:平台日志显示Action执行超时,但你的业务代码里明明很快就返回了。
排查链路:
- 如果Action内部用了同步HTTP库(比如
requests),且下游接口响应慢,那么每个Action实例都会占用一个工作线程等IO。当并发量上来,线程池被打满,新请求全部排队,表现出来就是"超时"。 - 解法有两个方向:一是把下游调用改成异步(
httpx.AsyncClient或aiohttp);二是在超时设置上留足余量,同时在下游调用内部设一个更短的超时。 - 不要只看平均值,要看P99。平时响应200ms的接口,在高峰期可能飙到3秒。
timeout_ms的设置要基于对下游P99的观测值来定,而不是平均值。
6.4 返回的文本格式化错误:忽略locale和模板渲染机制
症状:用户收到的回复内容是对的,但格式不合预期(比如时间格式不对、文案顺序错乱)。
排查链路:
- 查看Action返回的
outputs里的原始值。如果原始值正确,说明问题出在对话模板的渲染层。 - 检查模板里的占位符是否正确。有些平台要求模板中的变量引用用
{{outputs.order_status}}这种语法,如果少写了一个花括号,渲染结果就会变成变量名字面量。 - 注意
locale的传递。如果用户在对话里用了中文,而你的Action返回的message里写的是"Order 10086 is shipped",那平台大概率会直接展示这句英文。要在Action里就根据用户的locale组装对应的文案,不要把国际化压力丢给模板层。
6.5 多个Action同时匹配同一个意图:优先级不是摆设
症状:给某个意图新增了一个Action后,旧的行为变了。
原因:平台允许一个意图被多个Action监听,这种情况下通常会按照manifest里的优先级(priority字段)选择最高优先级的Action执行。你把新Action的优先级设得比旧的高,行为自然会变。
解法:在注册Action前,先查一下该意图是否已经被其他Action监听。如果被监听了,要么显式设置优先级,要么把新逻辑合并到旧Action里。最忌讳的是两个Action的优先级相同,这时平台的行为通常是不确定的(有的平台直接报错,有的随机选一个)。
6.6 Build报错信息不够直观时的兜底排查法
症状:platform-cli build报错,但错误信息只是"构建失败,请检查配置"这类笼统提示。
兜底排查法:
- 手动编译代码。如果用的是Python,可以先进工程目录跑
python -m py_compile $(find . -name "*.py"),这一步能暴露出语法错误和缩进问题。 - 手动校验manifest的JSON合法性。有时多了一个逗号或者少了一个引号,平台的解析器会给出很迷惑的错误信息。
- 检查
requirements.txt里是否有无法解析的依赖(比如私有源上的包)。构建机通常无法访问你的私有npm/pip源,这类依赖要么改成公共源可用版本,要么提供vendor目录。
写在最后的小体会
从"添加一个简单操作"这个入口出发,其实能牵扯出相当多值得打磨的东西——意图设计、参数声明、超时控制、错误文案、日志链路、安全校验。这个章节叫"Simple",但真正用好的Action绝不"Simple"。
我个人最大的体会是:Action开发的核心难点不在写代码,而在理解平台帮你做了什么、没帮你做什么。平台帮你做了意图识别和槽位提取,但没帮你做参数校验;平台帮你做了超时兜底,但没帮你做下游超时控制;平台帮你做了多轮追问,但前提是你的manifest声明得对。把这些边界想清楚,后面做任何复杂的操作逻辑都会顺很多。
如果你也是第一次接触这类平台,建议先不急着写业务代码,花半小时把你手头这个Action的manifest文件里的每一个字段查一遍含义,再对照文档确认意图和槽位的配置。这个半小时的投入,大概率能帮你省掉后面一整天的排查时间。
