跳转到主要内容

调用 Expert 智能体

本教程将带你完成从零开始在外部应用中调用 Profy Expert 的全流程——从获取 Expert 标识到构建完整的流式聊天界面。

你将构建什么

一个可以调用 Profy Expert 的后端服务,并将 SSE 流式响应接入前端聊天界面。完成后,你的应用将具备:
  • 调用任意已发布 Expert 的能力
  • 多轮对话上下文维持
  • SSE 流式解析与错误处理
  • 按 token 用量自动计费(METERED 模型)

Expert 是什么

Expert 是 Profy 平台上的 AI Agent 产品,由创作者在 Marketplace 上发布。每个 Expert 包含:
组成说明
Persona人设与行为风格定义
Tools可调用的工具集(搜索、代码执行、文件操作等)
Memory跨会话记忆,持续积累用户偏好与事实
Skills可复用的结构化技能,定义 Agent 的行为模式
通过 Events API 的 /openapi/v1/events/invoke 端点,你的应用可以像调用一个 API 一样调用这些 Expert。

前置条件

在开始之前,确保你已具备:
  • Profy App:在 Studio 中创建并配置好 OAuth
  • OAuth Access Token:通过授权码流程获取(参见 SDK 快速开始
  • 目标 Expert Identifier:你要调用的 Expert 的唯一标识
POST /openapi/v1/events/invoke 需要 events:write OAuth scope。确保你的 App 在创建时申请了该权限。

Step 1: 找到 Expert Identifier

每个已发布的 Expert 都有一个唯一的 identifier(slug 格式),用于 API 调用。 获取方式:
  1. Marketplace 页面 — 打开 Expert 详情页,URL 中的最后一段路径即为 identifier,如 https://app.profy.cn/expert/data-analystdata-analyst
  2. Studio — 如果你是 Expert 的创作者,在编辑页面的基本信息中可以看到 identifier
建议将 Expert Identifier 存入环境变量或配置文件,避免硬编码。

Step 2: 单轮调用

调用 Expert 的核心是向 /openapi/v1/events/invoke 发送 POST 请求,返回 SSE 流。
const BASE_URL = "https://app.profy.cn";

async function invokeExpert(
  accessToken: string,
  expertIdentifier: string,
  message: string
): Promise<string> {
  const res = await fetch(`${BASE_URL}/openapi/v1/events/invoke`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${accessToken}`,
    },
    body: JSON.stringify({
      expert_identifier: expertIdentifier,
      message,
    }),
  });

  if (!res.ok) {
    throw new Error(`Invoke failed: ${res.status} ${res.statusText}`);
  }

  let fullText = "";
  const reader = res.body!.getReader();
  const decoder = new TextDecoder();

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;

    const chunk = decoder.decode(value, { stream: true });
    for (const line of chunk.split("\n")) {
      if (line.startsWith("data: ")) {
        const data = JSON.parse(line.slice(6));
        if (data.type === "text") {
          fullText += data.content;
        }
      }
    }
  }

  return fullText;
}

const answer = await invokeExpert(token.accessToken, "data-analyst", "分析一下最近的销售趋势");
console.log(answer);
import httpx

BASE_URL = "https://app.profy.cn"

async def invoke_expert(
    access_token: str,
    expert_identifier: str,
    message: str,
) -> str:
    full_text = ""

    async with httpx.AsyncClient() as client:
        async with client.stream(
            "POST",
            f"{BASE_URL}/openapi/v1/events/invoke",
            headers={
                "Content-Type": "application/json",
                "Authorization": f"Bearer {access_token}",
            },
            json={
                "expert_identifier": expert_identifier,
                "message": message,
            },
        ) as response:
            response.raise_for_status()

            async for line in response.aiter_lines():
                if line.startswith("data: "):
                    import json
                    data = json.loads(line[6:])
                    if data["type"] == "text":
                        full_text += data["content"]

    return full_text

import asyncio
answer = asyncio.run(
    invoke_expert(access_token, "data-analyst", "分析一下最近的销售趋势")
)
print(answer)

Step 3: 多轮对话

通过传递 session_id 参数,Expert 会在同一会话上下文中延续对话,保留之前的消息历史和记忆。
import { randomUUID } from "crypto";

const sessionId = randomUUID();

const answer1 = await invokeExpertWithSession(
  token.accessToken,
  "data-analyst",
  "帮我分析一下 Q1 的用户增长数据",
  sessionId
);

const answer2 = await invokeExpertWithSession(
  token.accessToken,
  "data-analyst",
  "和 Q4 相比有什么变化?",
  sessionId  // 同一个 session_id,Expert 记得上一轮的内容
);

async function invokeExpertWithSession(
  accessToken: string,
  expertIdentifier: string,
  message: string,
  sessionId: string
): Promise<string> {
  const res = await fetch(`${BASE_URL}/openapi/v1/events/invoke`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${accessToken}`,
    },
    body: JSON.stringify({
      expert_identifier: expertIdentifier,
      message,
      session_id: sessionId,
    }),
  });

  if (!res.ok) {
    throw new Error(`Invoke failed: ${res.status}`);
  }

  let fullText = "";
  const reader = res.body!.getReader();
  const decoder = new TextDecoder();

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;

    const chunk = decoder.decode(value, { stream: true });
    for (const line of chunk.split("\n")) {
      if (line.startsWith("data: ")) {
        const data = JSON.parse(line.slice(6));
        if (data.type === "text") {
          fullText += data.content;
        }
      }
    }
  }

  return fullText;
}
import uuid

session_id = str(uuid.uuid4())

answer1 = await invoke_expert_with_session(
    access_token, "data-analyst",
    "帮我分析一下 Q1 的用户增长数据",
    session_id,
)

answer2 = await invoke_expert_with_session(
    access_token, "data-analyst",
    "和 Q4 相比有什么变化?",
    session_id,  # 同一个 session_id,Expert 记得上一轮的内容
)

async def invoke_expert_with_session(
    access_token: str,
    expert_identifier: str,
    message: str,
    session_id: str,
) -> str:
    full_text = ""

    async with httpx.AsyncClient() as client:
        async with client.stream(
            "POST",
            f"{BASE_URL}/openapi/v1/events/invoke",
            headers={
                "Content-Type": "application/json",
                "Authorization": f"Bearer {access_token}",
            },
            json={
                "expert_identifier": expert_identifier,
                "message": message,
                "session_id": session_id,
            },
        ) as response:
            response.raise_for_status()

            async for line in response.aiter_lines():
                if line.startswith("data: "):
                    import json
                    data = json.loads(line[6:])
                    if data["type"] == "text":
                        full_text += data["content"]

    return full_text
session_id 由你的应用生成和管理。同一个 session_id 下的所有调用共享对话上下文。新的 session_id 会开启全新对话。

Step 4: 处理 SSE 流

Expert 的响应是标准 SSE(Server-Sent Events)流,包含以下事件类型:
事件说明数据字段
text文本内容片段content: string
tool_call_chunk工具调用信息name: string, arguments: string
complete对话轮次结束
error运行时错误message: string, code: string
下面是一个通用的 SSE 流解析器:
interface ExpertEvent {
  type: "text" | "tool_call_chunk" | "complete" | "error";
  content?: string;
  name?: string;
  arguments?: string;
  message?: string;
  code?: string;
}

async function* streamExpertEvents(
  response: Response
): AsyncGenerator<ExpertEvent> {
  const reader = response.body!.getReader();
  const decoder = new TextDecoder();
  let buffer = "";

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;

    buffer += decoder.decode(value, { stream: true });
    const lines = buffer.split("\n");
    buffer = lines.pop() ?? "";

    for (const line of lines) {
      if (line.startsWith("data: ")) {
        yield JSON.parse(line.slice(6)) as ExpertEvent;
      }
    }
  }
}

const response = await fetch(`${BASE_URL}/openapi/v1/events/invoke`, {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    Authorization: `Bearer ${accessToken}`,
  },
  body: JSON.stringify({
    expert_identifier: "data-analyst",
    message: "生成季度报告",
  }),
});

for await (const event of streamExpertEvents(response)) {
  switch (event.type) {
    case "text":
      process.stdout.write(event.content ?? "");
      break;
    case "tool_call_chunk":
      console.log(`\n[Tool] ${event.name}(${event.arguments})`);
      break;
    case "complete":
      console.log("\n--- 完成 ---");
      break;
    case "error":
      console.error(`\n[Error] ${event.code}: ${event.message}`);
      break;
  }
}
from dataclasses import dataclass
from typing import AsyncIterator, Optional
import json

@dataclass
class ExpertEvent:
    type: str
    content: Optional[str] = None
    name: Optional[str] = None
    arguments: Optional[str] = None
    message: Optional[str] = None
    code: Optional[str] = None

async def stream_expert_events(
    response: httpx.Response,
) -> AsyncIterator[ExpertEvent]:
    async for line in response.aiter_lines():
        if line.startswith("data: "):
            data = json.loads(line[6:])
            yield ExpertEvent(**data)

async with httpx.AsyncClient() as client:
    async with client.stream(
        "POST",
        f"{BASE_URL}/openapi/v1/events/invoke",
        headers={
            "Content-Type": "application/json",
            "Authorization": f"Bearer {access_token}",
        },
        json={
            "expert_identifier": "data-analyst",
            "message": "生成季度报告",
        },
    ) as response:
        response.raise_for_status()

        async for event in stream_expert_events(response):
            match event.type:
                case "text":
                    print(event.content, end="", flush=True)
                case "tool_call_chunk":
                    print(f"\n[Tool] {event.name}({event.arguments})")
                case "complete":
                    print("\n--- 完成 ---")
                case "error":
                    print(f"\n[Error] {event.code}: {event.message}")

Step 5: 错误处理

invoke 端点可能返回以下错误,你的应用需要针对性处理:
HTTP 状态码含义处理方式
400请求参数错误检查 expert_identifiermessage 字段
401Token 无效或过期刷新 Access Token 后重试
403用户无权访问该 Expert引导用户到 Marketplace 购买/订阅
404Expert 不存在检查 identifier 是否正确
402用户积分不足提示用户充值
502Agent Runtime 错误稍后重试,若持续则联系平台
async function safeInvoke(
  accessToken: string,
  expertIdentifier: string,
  message: string,
  sessionId?: string
): Promise<{ ok: true; text: string } | { ok: false; error: string }> {
  const res = await fetch(`${BASE_URL}/openapi/v1/events/invoke`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${accessToken}`,
    },
    body: JSON.stringify({
      expert_identifier: expertIdentifier,
      message,
      ...(sessionId && { session_id: sessionId }),
    }),
  });

  if (!res.ok) {
    switch (res.status) {
      case 401:
        return { ok: false, error: "TOKEN_EXPIRED" };
      case 402:
        return { ok: false, error: "INSUFFICIENT_BALANCE" };
      case 403:
        return { ok: false, error: "ACCESS_DENIED" };
      case 404:
        return { ok: false, error: "EXPERT_NOT_FOUND" };
      case 502:
        return { ok: false, error: "RUNTIME_ERROR" };
      default:
        return { ok: false, error: `UNKNOWN_${res.status}` };
    }
  }

  let text = "";
  for await (const event of streamExpertEvents(res)) {
    if (event.type === "text") text += event.content ?? "";
    if (event.type === "error") return { ok: false, error: event.message ?? "STREAM_ERROR" };
  }

  return { ok: true, text };
}
async def safe_invoke(
    access_token: str,
    expert_identifier: str,
    message: str,
    session_id: str | None = None,
) -> dict:
    payload = {
        "expert_identifier": expert_identifier,
        "message": message,
    }
    if session_id:
        payload["session_id"] = session_id

    async with httpx.AsyncClient() as client:
        try:
            async with client.stream(
                "POST",
                f"{BASE_URL}/openapi/v1/events/invoke",
                headers={
                    "Content-Type": "application/json",
                    "Authorization": f"Bearer {access_token}",
                },
                json=payload,
            ) as response:
                if response.status_code == 401:
                    return {"ok": False, "error": "TOKEN_EXPIRED"}
                if response.status_code == 402:
                    return {"ok": False, "error": "INSUFFICIENT_BALANCE"}
                if response.status_code == 403:
                    return {"ok": False, "error": "ACCESS_DENIED"}
                if response.status_code == 404:
                    return {"ok": False, "error": "EXPERT_NOT_FOUND"}
                if response.status_code == 502:
                    return {"ok": False, "error": "RUNTIME_ERROR"}

                response.raise_for_status()

                text = ""
                async for event in stream_expert_events(response):
                    if event.type == "text":
                        text += event.content or ""
                    if event.type == "error":
                        return {"ok": False, "error": event.message or "STREAM_ERROR"}

                return {"ok": True, "text": text}

        except httpx.HTTPStatusError:
            return {"ok": False, "error": f"UNKNOWN_{response.status_code}"}
403 通常意味着用户尚未购买该 Expert。对于 METERED 类型的 Expert,用户需要先在 Marketplace 完成订阅;对于 ONE_TIME 类型,用户需要完成一次性购买。

Step 6: 构建聊天界面

将 SSE 流接入前端聊天界面的最小示例:
"use client";

import { useState, useCallback } from "react";

interface Message {
  role: "user" | "assistant";
  content: string;
}

export function ExpertChat({ expertId }: { expertId: string }) {
  const [messages, setMessages] = useState<Message[]>([]);
  const [input, setInput] = useState("");
  const [loading, setLoading] = useState(false);
  const [sessionId] = useState(() => crypto.randomUUID());

  const send = useCallback(async () => {
    if (!input.trim() || loading) return;

    const userMsg: Message = { role: "user", content: input };
    setMessages((prev) => [...prev, userMsg]);
    setInput("");
    setLoading(true);

    const assistantMsg: Message = { role: "assistant", content: "" };
    setMessages((prev) => [...prev, assistantMsg]);

    try {
      const res = await fetch("/api/expert/invoke", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          expertId,
          message: input,
          sessionId,
        }),
      });

      const reader = res.body!.getReader();
      const decoder = new TextDecoder();

      while (true) {
        const { done, value } = await reader.read();
        if (done) break;

        const chunk = decoder.decode(value, { stream: true });
        for (const line of chunk.split("\n")) {
          if (!line.startsWith("data: ")) continue;
          const data = JSON.parse(line.slice(6));
          if (data.type === "text") {
            setMessages((prev) => {
              const updated = [...prev];
              const last = updated[updated.length - 1];
              updated[updated.length - 1] = {
                ...last,
                content: last.content + (data.content ?? ""),
              };
              return updated;
            });
          }
        }
      }
    } finally {
      setLoading(false);
    }
  }, [input, loading, expertId, sessionId]);

  return (
    <div>
      <div>
        {messages.map((msg, i) => (
          <div key={i} data-role={msg.role}>
            {msg.content}
          </div>
        ))}
      </div>
      <input
        value={input}
        onChange={(e) => setInput(e.target.value)}
        onKeyDown={(e) => e.key === "Enter" && send()}
        placeholder="输入消息..."
        disabled={loading}
      />
    </div>
  );
}
import asyncio
import uuid
import httpx
import json

BASE_URL = "https://app.profy.cn"

async def chat_loop(access_token: str, expert_identifier: str):
    session_id = str(uuid.uuid4())
    print(f"开始与 {expert_identifier} 对话(输入 quit 退出)\n")

    while True:
        user_input = input("你: ")
        if user_input.strip().lower() == "quit":
            break

        print("Expert: ", end="", flush=True)

        async with httpx.AsyncClient() as client:
            async with client.stream(
                "POST",
                f"{BASE_URL}/openapi/v1/events/invoke",
                headers={
                    "Content-Type": "application/json",
                    "Authorization": f"Bearer {access_token}",
                },
                json={
                    "expert_identifier": expert_identifier,
                    "message": user_input,
                    "session_id": session_id,
                },
                timeout=60.0,
            ) as response:
                response.raise_for_status()

                async for line in response.aiter_lines():
                    if not line.startswith("data: "):
                        continue
                    data = json.loads(line[6:])
                    if data["type"] == "text":
                        print(data.get("content", ""), end="", flush=True)
                    elif data["type"] == "error":
                        print(f"\n[错误] {data.get('message', '')}")
                    elif data["type"] == "complete":
                        print()

        print()

asyncio.run(chat_loop("your_access_token", "data-analyst"))
前端不直接调用 Profy API——请求经过你的后端代理(/api/expert/invoke),后端持有 OAuth Token 并转发 SSE 流。这避免了在前端暴露 Access Token。

完整示例

一个可运行的后端服务,将 Expert 调用封装为 API 端点:
import { Hono } from "hono";
import { stream } from "hono/streaming";

const app = new Hono();
const PROFY_BASE = "https://app.profy.cn";

app.post("/api/expert/invoke", async (c) => {
  const { expertId, message, sessionId } = await c.req.json();
  const accessToken = await getAccessTokenForUser(c);

  const upstream = await fetch(`${PROFY_BASE}/openapi/v1/events/invoke`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${accessToken}`,
    },
    body: JSON.stringify({
      expert_identifier: expertId,
      message,
      session_id: sessionId,
    }),
  });

  if (!upstream.ok) {
    return c.json({ error: `upstream_${upstream.status}` }, upstream.status as 400);
  }

  return stream(c, async (s) => {
    const reader = upstream.body!.getReader();
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      await s.write(value);
    }
  });
});

export default app;
from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse
import httpx

app = FastAPI()
PROFY_BASE = "https://app.profy.cn"

@app.post("/api/expert/invoke")
async def invoke_expert(request: Request):
    body = await request.json()
    access_token = await get_access_token_for_user(request)

    async def proxy_stream():
        async with httpx.AsyncClient() as client:
            async with client.stream(
                "POST",
                f"{PROFY_BASE}/openapi/v1/events/invoke",
                headers={
                    "Content-Type": "application/json",
                    "Authorization": f"Bearer {access_token}",
                },
                json={
                    "expert_identifier": body["expertId"],
                    "message": body["message"],
                    "session_id": body.get("sessionId"),
                },
            ) as response:
                response.raise_for_status()
                async for chunk in response.aiter_bytes():
                    yield chunk

    return StreamingResponse(
        proxy_stream(),
        media_type="text/event-stream",
    )

Expert 调用 vs AI 模型调用

Profy Events API 提供两种 AI 调用方式。根据你的场景选择合适的端点:
维度Expert 调用 (/events/invoke)AI 模型调用 (/events/chat)
调用对象特定 Expert(含 persona + tools + memory)通用 AI 模型(OpenAI 兼容)
请求格式{ expert_identifier, message }{ model, messages }
上下文管理平台自动管理(session_id应用自行维护 messages 数组
工具调用Expert 自带工具,平台自动编排需要应用自行定义和处理
记忆Expert 内置跨会话记忆无记忆,每次调用独立
适用场景领域专家任务(数据分析、客服、写作)通用文本生成、翻译、摘要
计费METERED(按 token)METERED(按 token)
人设定制创作者预定义,调用方无需配置应用自行通过 system prompt 定义
如果你需要的是一个「开箱即用的领域专家」,用 Expert 调用;如果你需要的是「底层模型能力」并自行编排,用 AI 模型调用。两者可以在同一个应用中混合使用。

下一步

SDK 快速开始

安装 SDK、完成 OAuth 对接和首次事件上报

AI 模型调用

使用 OpenAI 兼容接口调用平台 AI 模型

应用接入教程

从零创建 App、配置 OAuth、上架市场

API 参考

Events Invoke 端点完整字段说明