从零写一个 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码退出并发errorScope 事件。
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/v1,model: soya:research-summarizer。满意后镜像到生产:
soyaos pull --moon https://stage.example.com soya:[email protected]
soyaos push --moon https://prod.example.com soya:[email protected]
想看更多
- 能力与沙箱 ——
egress、fs、determinism_tier实际怎么强制。 - 虚拟模型与 BYOK ——
upstream:块怎么解析。 - SoyaPack v0 清单 —— 每个字段。
- 签名与发布 SoyaPack —— 准备上生产时看。