SoyaOS

从零写一个 Agent

搭一个多阶段 Agent——抓 URL、提要点、产出类型化产物。

这篇带你做一个真实的 Agent——比快速上手echo 模板深一档。我们要做 research-summarizer:接一个 URL,抓页面,调上游 LLM 提 5–10 条要点,产出一个 summary.v1 产物。

直接复制粘贴五分钟;想把每段背后的为什么也吃透,30 分钟。

你会学到

  • 怎么把 tools.fetch 接到一条狭窄的 egress 白名单。
  • 怎么把两个 prompt 阶段(extract + summarize)串起来,让 LLM 第二轮看到的是清洁过的输入。
  • 怎么给产物声明类型并校验。
  • 怎么写 fixture 让 pack lint 不放水。

1. 脚手架

soyaos pack init research-summarizer --template chat
cd research-summarizer

chat 模板给你一个单阶段、调上游 LLM 的 Agent。我们把它扩成两阶段,再加一个工具。

2. 清单

把生成的 soyapack.yaml 替换成:

apiVersion: soyaos.ai/v0
kind: Agent
name: research-summarizer
version: 0.1.0
virtual_model_id: soya:research-summarizer
description: 抓一个 URL,产出 5–10 条要点。
owner: chzealot
license: MIT

capabilities:
  egress:
    - host: api.openai.com
      port: 443
      protocol: https
  fs:
    read:
      - /workdir
    write:
      - /workdir/out
  determinism_tier: read-only

inputs:
  type: object
  required: [url]
  properties:
    url:        { type: string, format: uri }
    max_bullets: { type: integer, default: 8, minimum: 3, maximum: 12 }

outputs:
  summary: summary.v1

prompts:
  extract:   prompts/extract.md
  summarize: prompts/summarize.md

tools:
  fetch:
    kind: builtin.http_get
    params: { timeout: 15s, max_bytes: 2_000_000 }

upstream:
  prefer: openai

examples:
  short_blog:
    input: { url: "https://example.com/post", max_bullets: 5 }

工具 egress 是最容易踩的坑。builtin.http_get 走包的 egress 白名单——你要抓 example.com 就得把它也加进去。这个示例清单为了简洁只列了一个主机;真实 Agent 要么显式加目的地,要么走 Moon 端代理。

3. 提示词

prompts/extract.md ——把抓回来的 HTML 里的正文挤干净:

你会收到一份 HTML 文档在 `{{ fetched_html }}` 里。

只返回文章正文的纯文本。去掉 nav、header、footer、广告、评论、
「相关文章」。段落间用空行分隔。

如果页面明显不是文章(登录页、搜索结果、404),返回恰好这一串:
`<<NON_ARTICLE>>`,别的什么都别写。

prompts/summarize.md ——把干净的正文变成要点:

你在把一篇文章总结成 {{ max_bullets }} 条要点。

# 文章

{{ article_text }}

# 规则

- 每条只一个论断,不灌水。
- 最强的论断放第一条。
- 如果文章有反方论点,作为最后一条,前缀 "Counter:"。
- 输出为字符串 JSON 数组,无 markdown、无前后赘述。

4. 阶段编排

新增 pipeline.yaml 描述阶段连接:

# pipeline.yaml
stages:
  - name: extract
    when: always
    inputs:
      fetched_html: '{{ tools.fetch(url=inputs.url).body }}'
    on: prompts.extract
    output: article_text

  - name: summarize
    when: 'article_text != "<<NON_ARTICLE>>"'
    inputs:
      article_text: '{{ stages.extract.output }}'
      max_bullets:  '{{ inputs.max_bullets }}'
    on: prompts.summarize
    output: bullets
    parse_as: 'json:string[]'

outputs:
  summary:
    bullets: '{{ stages.summarize.output }}'
    source_url: '{{ inputs.url }}'

要点:

  • tools.fetch(...) 按清单 tools: 块求值。Comet 在调用时校验 egress 白名单。
  • when: 让我们在 extract 返回哨兵值时跳过第二阶段——省一次 LLM 调用。
  • parse_as: 'json:string[]' 校验 LLM 输出确实是字符串 JSON 数组;解析失败以 2 码退出并发 error Scope 事件。

5. 样例 fixture

examples/short_blog.json

{
  "url": "https://example.com/post",
  "max_bullets": 5,
  "_expected_kind": "article"
}

_expected_kind 是给 pack lint 看的元数据——它不会传到 Agent,但 lint 看到这条声称是「article」的输入却被 extract 返回 <<NON_ARTICLE>> 时会告警。

6. 校验、lint、运行

soyaos pack validate .
soyaos pack lint . --strict
soyaos run . --input @examples/short_blog.json

接通了的话:

 research-summarizer @0.1.0 · 2 stages · capabilities: egress[1]
  extract     ████████████  2.1s
  summarize   ████████████  3.4s
 run_018f3a · ok in 5.51s
{
  "summary": {
    "bullets": [
      "写入密集型表过约 5000 万行后,分布式 compaction 比纵向扩缩更划算。",
      "Bloom 过滤器假阳率会跨层级累积——调要调 level 0,不是 leaf。",
      "Counter: tiered compaction 在峰值写入时浪费 2-3 倍磁盘。"
    ],
    "source_url": "https://example.com/post"
  }
}

7. 快速迭代

soyaos run --watch . 在你改 prompt 文件时自动重跑。配合 --json | jq 实时看 Scope 事件:

soyaos --json run . --input @examples/short_blog.json --watch | \
  jq -c 'select(.kind == "llm_response") | {tokens: .completion_tokens, stage}'

8. ship

soyaos pack push . --moon https://stage.example.com

把客户端指向 stage.example.com/v1model: soya:research-summarizer。满意后镜像到生产:

soyaos pull --moon https://stage.example.com soya:[email protected]
soyaos push --moon https://prod.example.com  soya:[email protected]

想看更多

在 GitHub 上编辑本页